+2
.tangled/workflows/build.yml
+2
.tangled/workflows/build.yml
+2
.tangled/workflows/fmt.yml
+2
.tangled/workflows/fmt.yml
+2
.tangled/workflows/test.yml
+2
.tangled/workflows/test.yml
+2
-122
api/tangled/cbor_gen.go
+2
-122
api/tangled/cbor_gen.go
···
5512
5512
}
5513
5513
5514
5514
cw := cbg.NewCborWriter(w)
5515
-
fieldCount := 7
5515
+
fieldCount := 6
5516
5516
5517
5517
if t.Body == nil {
5518
5518
fieldCount--
···
5642
5642
return err
5643
5643
}
5644
5644
5645
-
// t.IssueId (int64) (int64)
5646
-
if len("issueId") > 1000000 {
5647
-
return xerrors.Errorf("Value in field \"issueId\" was too long")
5648
-
}
5649
-
5650
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil {
5651
-
return err
5652
-
}
5653
-
if _, err := cw.WriteString(string("issueId")); err != nil {
5654
-
return err
5655
-
}
5656
-
5657
-
if t.IssueId >= 0 {
5658
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil {
5659
-
return err
5660
-
}
5661
-
} else {
5662
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil {
5663
-
return err
5664
-
}
5665
-
}
5666
-
5667
5645
// t.CreatedAt (string) (string)
5668
5646
if len("createdAt") > 1000000 {
5669
5647
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
5795
5773
5796
5774
t.Title = string(sval)
5797
5775
}
5798
-
// t.IssueId (int64) (int64)
5799
-
case "issueId":
5800
-
{
5801
-
maj, extra, err := cr.ReadHeader()
5802
-
if err != nil {
5803
-
return err
5804
-
}
5805
-
var extraI int64
5806
-
switch maj {
5807
-
case cbg.MajUnsignedInt:
5808
-
extraI = int64(extra)
5809
-
if extraI < 0 {
5810
-
return fmt.Errorf("int64 positive overflow")
5811
-
}
5812
-
case cbg.MajNegativeInt:
5813
-
extraI = int64(extra)
5814
-
if extraI < 0 {
5815
-
return fmt.Errorf("int64 negative overflow")
5816
-
}
5817
-
extraI = -1 - extraI
5818
-
default:
5819
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
5820
-
}
5821
-
5822
-
t.IssueId = int64(extraI)
5823
-
}
5824
5776
// t.CreatedAt (string) (string)
5825
5777
case "createdAt":
5826
5778
···
5850
5802
}
5851
5803
5852
5804
cw := cbg.NewCborWriter(w)
5853
-
fieldCount := 7
5854
-
5855
-
if t.CommentId == nil {
5856
-
fieldCount--
5857
-
}
5805
+
fieldCount := 6
5858
5806
5859
5807
if t.Owner == nil {
5860
5808
fieldCount--
···
5997
5945
}
5998
5946
}
5999
5947
6000
-
// t.CommentId (int64) (int64)
6001
-
if t.CommentId != nil {
6002
-
6003
-
if len("commentId") > 1000000 {
6004
-
return xerrors.Errorf("Value in field \"commentId\" was too long")
6005
-
}
6006
-
6007
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil {
6008
-
return err
6009
-
}
6010
-
if _, err := cw.WriteString(string("commentId")); err != nil {
6011
-
return err
6012
-
}
6013
-
6014
-
if t.CommentId == nil {
6015
-
if _, err := cw.Write(cbg.CborNull); err != nil {
6016
-
return err
6017
-
}
6018
-
} else {
6019
-
if *t.CommentId >= 0 {
6020
-
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil {
6021
-
return err
6022
-
}
6023
-
} else {
6024
-
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil {
6025
-
return err
6026
-
}
6027
-
}
6028
-
}
6029
-
6030
-
}
6031
-
6032
5948
// t.CreatedAt (string) (string)
6033
5949
if len("createdAt") > 1000000 {
6034
5950
return xerrors.Errorf("Value in field \"createdAt\" was too long")
···
6168
6084
}
6169
6085
6170
6086
t.Owner = (*string)(&sval)
6171
-
}
6172
-
}
6173
-
// t.CommentId (int64) (int64)
6174
-
case "commentId":
6175
-
{
6176
-
6177
-
b, err := cr.ReadByte()
6178
-
if err != nil {
6179
-
return err
6180
-
}
6181
-
if b != cbg.CborNull[0] {
6182
-
if err := cr.UnreadByte(); err != nil {
6183
-
return err
6184
-
}
6185
-
maj, extra, err := cr.ReadHeader()
6186
-
if err != nil {
6187
-
return err
6188
-
}
6189
-
var extraI int64
6190
-
switch maj {
6191
-
case cbg.MajUnsignedInt:
6192
-
extraI = int64(extra)
6193
-
if extraI < 0 {
6194
-
return fmt.Errorf("int64 positive overflow")
6195
-
}
6196
-
case cbg.MajNegativeInt:
6197
-
extraI = int64(extra)
6198
-
if extraI < 0 {
6199
-
return fmt.Errorf("int64 negative overflow")
6200
-
}
6201
-
extraI = -1 - extraI
6202
-
default:
6203
-
return fmt.Errorf("wrong type for int64 field: %d", maj)
6204
-
}
6205
-
6206
-
t.CommentId = (*int64)(&extraI)
6207
6087
}
6208
6088
}
6209
6089
// t.CreatedAt (string) (string)
-1
api/tangled/issuecomment.go
-1
api/tangled/issuecomment.go
···
19
19
type RepoIssueComment struct {
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"`
21
21
Body string `json:"body" cborgen:"body"`
22
-
CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"`
23
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
24
23
Issue string `json:"issue" cborgen:"issue"`
25
24
Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"`
-1
api/tangled/repoissue.go
-1
api/tangled/repoissue.go
···
20
20
LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"`
21
21
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
22
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
IssueId int64 `json:"issueId" cborgen:"issueId"`
24
23
Owner string `json:"owner" cborgen:"owner"`
25
24
Repo string `json:"repo" cborgen:"repo"`
26
25
Title string `json:"title" cborgen:"title"`
+4
appview/db/db.go
+4
appview/db/db.go
···
470
470
id integer primary key autoincrement,
471
471
name text unique
472
472
);
473
+
474
+
-- indexes for better star query performance
475
+
create index if not exists idx_stars_created on stars(created);
476
+
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
473
477
`)
474
478
if err != nil {
475
479
return nil, err
+189
-3
appview/db/issues.go
+189
-3
appview/db/issues.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
+
mathrand "math/rand/v2"
7
+
"strings"
6
8
"time"
7
9
8
10
"github.com/bluesky-social/indigo/atproto/syntax"
···
48
50
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
49
51
}
50
52
53
+
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
54
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
55
+
if err != nil {
56
+
created = time.Now()
57
+
}
58
+
59
+
body := ""
60
+
if record.Body != nil {
61
+
body = *record.Body
62
+
}
63
+
64
+
return Issue{
65
+
RepoAt: syntax.ATURI(record.Repo),
66
+
OwnerDid: record.Owner,
67
+
Rkey: rkey,
68
+
Created: created,
69
+
Title: record.Title,
70
+
Body: body,
71
+
Open: true, // new issues are open by default
72
+
}
73
+
}
74
+
75
+
func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
76
+
ownerDid := issueUri.Authority().String()
77
+
issueRkey := issueUri.RecordKey().String()
78
+
79
+
var repoAt string
80
+
var issueId int
81
+
82
+
query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
83
+
err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
84
+
if err != nil {
85
+
return "", 0, err
86
+
}
87
+
88
+
return syntax.ATURI(repoAt), issueId, nil
89
+
}
90
+
91
+
func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
92
+
created, err := time.Parse(time.RFC3339, record.CreatedAt)
93
+
if err != nil {
94
+
created = time.Now()
95
+
}
96
+
97
+
ownerDid := did
98
+
if record.Owner != nil {
99
+
ownerDid = *record.Owner
100
+
}
101
+
102
+
issueUri, err := syntax.ParseATURI(record.Issue)
103
+
if err != nil {
104
+
return Comment{}, err
105
+
}
106
+
107
+
repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
108
+
if err != nil {
109
+
return Comment{}, err
110
+
}
111
+
112
+
comment := Comment{
113
+
OwnerDid: ownerDid,
114
+
RepoAt: repoAt,
115
+
Rkey: rkey,
116
+
Body: record.Body,
117
+
Issue: issueId,
118
+
CommentId: mathrand.IntN(1000000),
119
+
Created: &created,
120
+
}
121
+
122
+
return comment, nil
123
+
}
124
+
51
125
func NewIssue(tx *sql.Tx, issue *Issue) error {
52
126
defer tx.Rollback()
53
127
···
105
179
return ownerDid, err
106
180
}
107
181
108
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
182
+
func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
109
183
var issues []Issue
110
184
openValue := 0
111
185
if isOpen {
···
145
219
body,
146
220
open,
147
221
comment_count
148
-
from
222
+
from
149
223
numbered_issue
150
-
where
224
+
where
151
225
row_num between ? and ?`,
152
226
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
153
227
if err != nil {
···
181
255
return issues, nil
182
256
}
183
257
258
+
func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
259
+
issues := make([]Issue, 0, limit)
260
+
261
+
var conditions []string
262
+
var args []any
263
+
for _, filter := range filters {
264
+
conditions = append(conditions, filter.Condition())
265
+
args = append(args, filter.Arg()...)
266
+
}
267
+
268
+
whereClause := ""
269
+
if conditions != nil {
270
+
whereClause = " where " + strings.Join(conditions, " and ")
271
+
}
272
+
limitClause := ""
273
+
if limit != 0 {
274
+
limitClause = fmt.Sprintf(" limit %d ", limit)
275
+
}
276
+
277
+
query := fmt.Sprintf(
278
+
`select
279
+
i.id,
280
+
i.owner_did,
281
+
i.repo_at,
282
+
i.issue_id,
283
+
i.created,
284
+
i.title,
285
+
i.body,
286
+
i.open
287
+
from
288
+
issues i
289
+
%s
290
+
order by
291
+
i.created desc
292
+
%s`,
293
+
whereClause, limitClause)
294
+
295
+
rows, err := e.Query(query, args...)
296
+
if err != nil {
297
+
return nil, err
298
+
}
299
+
defer rows.Close()
300
+
301
+
for rows.Next() {
302
+
var issue Issue
303
+
var issueCreatedAt string
304
+
err := rows.Scan(
305
+
&issue.ID,
306
+
&issue.OwnerDid,
307
+
&issue.RepoAt,
308
+
&issue.IssueId,
309
+
&issueCreatedAt,
310
+
&issue.Title,
311
+
&issue.Body,
312
+
&issue.Open,
313
+
)
314
+
if err != nil {
315
+
return nil, err
316
+
}
317
+
318
+
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
319
+
if err != nil {
320
+
return nil, err
321
+
}
322
+
issue.Created = issueCreatedTime
323
+
324
+
issues = append(issues, issue)
325
+
}
326
+
327
+
if err := rows.Err(); err != nil {
328
+
return nil, err
329
+
}
330
+
331
+
return issues, nil
332
+
}
333
+
334
+
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
335
+
return GetIssuesWithLimit(e, 0, filters...)
336
+
}
337
+
184
338
// timeframe here is directly passed into the sql query filter, and any
185
339
// timeframe in the past should be negative; e.g.: "-3 months"
186
340
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
···
469
623
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
470
624
where repo_at = ? and issue_id = ? and comment_id = ?
471
625
`, repoAt, issueId, commentId)
626
+
return err
627
+
}
628
+
629
+
func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
630
+
_, err := e.Exec(
631
+
`
632
+
update comments
633
+
set body = ?,
634
+
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
635
+
where owner_did = ? and rkey = ?
636
+
`, newBody, ownerDid, rkey)
637
+
return err
638
+
}
639
+
640
+
func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
641
+
_, err := e.Exec(
642
+
`
643
+
update comments
644
+
set body = "",
645
+
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
646
+
where owner_did = ? and rkey = ?
647
+
`, ownerDid, rkey)
648
+
return err
649
+
}
650
+
651
+
func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
652
+
_, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
653
+
return err
654
+
}
655
+
656
+
func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
657
+
_, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
472
658
return err
473
659
}
474
660
+22
-3
appview/db/pulls.go
+22
-3
appview/db/pulls.go
···
310
310
return pullId - 1, err
311
311
}
312
312
313
-
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
313
+
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) {
314
314
pulls := make(map[int]*Pull)
315
315
316
316
var conditions []string
···
323
323
whereClause := ""
324
324
if conditions != nil {
325
325
whereClause = " where " + strings.Join(conditions, " and ")
326
+
}
327
+
limitClause := ""
328
+
if limit != 0 {
329
+
limitClause = fmt.Sprintf(" limit %d ", limit)
326
330
}
327
331
328
332
query := fmt.Sprintf(`
···
344
348
from
345
349
pulls
346
350
%s
347
-
`, whereClause)
351
+
order by
352
+
created desc
353
+
%s
354
+
`, whereClause, limitClause)
348
355
349
356
rows, err := e.Query(query, args...)
350
357
if err != nil {
···
412
419
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
413
420
submissionsQuery := fmt.Sprintf(`
414
421
select
415
-
id, pull_id, round_number, patch, source_rev
422
+
id, pull_id, round_number, patch, created, source_rev
416
423
from
417
424
pull_submissions
418
425
where
···
438
445
for submissionsRows.Next() {
439
446
var s PullSubmission
440
447
var sourceRev sql.NullString
448
+
var createdAt string
441
449
err := submissionsRows.Scan(
442
450
&s.ID,
443
451
&s.PullId,
444
452
&s.RoundNumber,
445
453
&s.Patch,
454
+
&createdAt,
446
455
&sourceRev,
447
456
)
448
457
if err != nil {
449
458
return nil, err
450
459
}
460
+
461
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
462
+
if err != nil {
463
+
return nil, err
464
+
}
465
+
s.Created = createdTime
451
466
452
467
if sourceRev.Valid {
453
468
s.SourceRev = sourceRev.String
···
511
526
})
512
527
513
528
return orderedByPullId, nil
529
+
}
530
+
531
+
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
532
+
return GetPullsWithLimit(e, 0, filters...)
514
533
}
515
534
516
535
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+72
-3
appview/db/star.go
+72
-3
appview/db/star.go
···
47
47
// Get a star record
48
48
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
49
49
query := `
50
-
select starred_by_did, repo_at, created, rkey
50
+
select starred_by_did, repo_at, created, rkey
51
51
from stars
52
52
where starred_by_did = ? and repo_at = ?`
53
53
row := e.QueryRow(query, starredByDid, repoAt)
···
119
119
}
120
120
121
121
repoQuery := fmt.Sprintf(
122
-
`select starred_by_did, repo_at, created, rkey
122
+
`select starred_by_did, repo_at, created, rkey
123
123
from stars
124
124
%s
125
125
order by created desc
···
187
187
var stars []Star
188
188
189
189
rows, err := e.Query(`
190
-
select
190
+
select
191
191
s.starred_by_did,
192
192
s.repo_at,
193
193
s.rkey,
···
244
244
245
245
return stars, nil
246
246
}
247
+
248
+
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
249
+
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
250
+
// first, get the top repo URIs by star count from the last week
251
+
query := `
252
+
with recent_starred_repos as (
253
+
select distinct repo_at
254
+
from stars
255
+
where created >= datetime('now', '-7 days')
256
+
),
257
+
repo_star_counts as (
258
+
select
259
+
s.repo_at,
260
+
count(*) as star_count
261
+
from stars s
262
+
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
263
+
group by s.repo_at
264
+
)
265
+
select rsc.repo_at
266
+
from repo_star_counts rsc
267
+
order by rsc.star_count desc
268
+
limit 8
269
+
`
270
+
271
+
rows, err := e.Query(query)
272
+
if err != nil {
273
+
return nil, err
274
+
}
275
+
defer rows.Close()
276
+
277
+
var repoUris []string
278
+
for rows.Next() {
279
+
var repoUri string
280
+
err := rows.Scan(&repoUri)
281
+
if err != nil {
282
+
return nil, err
283
+
}
284
+
repoUris = append(repoUris, repoUri)
285
+
}
286
+
287
+
if err := rows.Err(); err != nil {
288
+
return nil, err
289
+
}
290
+
291
+
if len(repoUris) == 0 {
292
+
return []Repo{}, nil
293
+
}
294
+
295
+
// get full repo data
296
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
297
+
if err != nil {
298
+
return nil, err
299
+
}
300
+
301
+
// sort repos by the original trending order
302
+
repoMap := make(map[string]Repo)
303
+
for _, repo := range repos {
304
+
repoMap[repo.RepoAt().String()] = repo
305
+
}
306
+
307
+
orderedRepos := make([]Repo, 0, len(repoUris))
308
+
for _, uri := range repoUris {
309
+
if repo, exists := repoMap[uri]; exists {
310
+
orderedRepos = append(orderedRepos, repo)
311
+
}
312
+
}
313
+
314
+
return orderedRepos, nil
315
+
}
+12
-11
appview/db/strings.go
+12
-11
appview/db/strings.go
···
50
50
func (s String) Validate() error {
51
51
var err error
52
52
53
-
if !strings.Contains(s.Filename, ".") {
54
-
err = errors.Join(err, fmt.Errorf("missing filename extension"))
55
-
}
56
-
57
-
if strings.HasSuffix(s.Filename, ".") {
58
-
err = errors.Join(err, fmt.Errorf("filename ends with `.`"))
59
-
}
60
-
61
53
if utf8.RuneCountInString(s.Filename) > 140 {
62
54
err = errors.Join(err, fmt.Errorf("filename too long"))
63
55
}
···
113
105
filename = excluded.filename,
114
106
description = excluded.description,
115
107
content = excluded.content,
116
-
edited = case
108
+
edited = case
117
109
when
118
110
strings.content != excluded.content
119
111
or strings.filename != excluded.filename
···
131
123
return err
132
124
}
133
125
134
-
func GetStrings(e Execer, filters ...filter) ([]String, error) {
126
+
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
135
127
var all []String
136
128
137
129
var conditions []string
···
146
138
whereClause = " where " + strings.Join(conditions, " and ")
147
139
}
148
140
141
+
limitClause := ""
142
+
if limit != 0 {
143
+
limitClause = fmt.Sprintf(" limit %d ", limit)
144
+
}
145
+
149
146
query := fmt.Sprintf(`select
150
147
did,
151
148
rkey,
···
154
151
content,
155
152
created,
156
153
edited
157
-
from strings %s`,
154
+
from strings
155
+
%s
156
+
order by created desc
157
+
%s`,
158
158
whereClause,
159
+
limitClause,
159
160
)
160
161
161
162
rows, err := e.Query(query, args...)
+179
-6
appview/ingester.go
+179
-6
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
+
"strings"
8
9
"time"
9
10
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
14
15
"tangled.sh/tangled.sh/core/api/tangled"
15
16
"tangled.sh/tangled.sh/core/appview/config"
16
17
"tangled.sh/tangled.sh/core/appview/db"
18
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
17
19
"tangled.sh/tangled.sh/core/appview/spindleverify"
18
20
"tangled.sh/tangled.sh/core/idresolver"
19
21
"tangled.sh/tangled.sh/core/rbac"
···
61
63
case tangled.ActorProfileNSID:
62
64
err = i.ingestProfile(e)
63
65
case tangled.SpindleMemberNSID:
64
-
err = i.ingestSpindleMember(e)
66
+
err = i.ingestSpindleMember(ctx, e)
65
67
case tangled.SpindleNSID:
66
-
err = i.ingestSpindle(e)
68
+
err = i.ingestSpindle(ctx, e)
67
69
case tangled.StringNSID:
68
70
err = i.ingestString(e)
71
+
case tangled.RepoIssueNSID:
72
+
err = i.ingestIssue(ctx, e)
73
+
case tangled.RepoIssueCommentNSID:
74
+
err = i.ingestIssueComment(e)
69
75
}
70
76
l = i.Logger.With("nsid", e.Commit.Collection)
71
77
}
···
336
342
return nil
337
343
}
338
344
339
-
func (i *Ingester) ingestSpindleMember(e *models.Event) error {
345
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
340
346
did := e.Did
341
347
var err error
342
348
···
359
365
return fmt.Errorf("failed to enforce permissions: %w", err)
360
366
}
361
367
362
-
memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject)
368
+
memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject)
363
369
if err != nil {
364
370
return err
365
371
}
···
442
448
return nil
443
449
}
444
450
445
-
func (i *Ingester) ingestSpindle(e *models.Event) error {
451
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
446
452
did := e.Did
447
453
var err error
448
454
···
475
481
return err
476
482
}
477
483
478
-
err = spindleverify.RunVerification(context.Background(), instance, did, i.Config.Core.Dev)
484
+
err = spindleverify.RunVerification(ctx, instance, did, i.Config.Core.Dev)
479
485
if err != nil {
480
486
l.Error("failed to add spindle to db", "err", err, "instance", instance)
481
487
return err
···
609
615
610
616
return nil
611
617
}
618
+
619
+
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
620
+
did := e.Did
621
+
rkey := e.Commit.RKey
622
+
623
+
var err error
624
+
625
+
l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
626
+
l.Info("ingesting record")
627
+
628
+
ddb, ok := i.Db.Execer.(*db.DB)
629
+
if !ok {
630
+
return fmt.Errorf("failed to index issue record, invalid db cast")
631
+
}
632
+
633
+
switch e.Commit.Operation {
634
+
case models.CommitOperationCreate:
635
+
raw := json.RawMessage(e.Commit.Record)
636
+
record := tangled.RepoIssue{}
637
+
err = json.Unmarshal(raw, &record)
638
+
if err != nil {
639
+
l.Error("invalid record", "err", err)
640
+
return err
641
+
}
642
+
643
+
issue := db.IssueFromRecord(did, rkey, record)
644
+
645
+
sanitizer := markup.NewSanitizer()
646
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(issue.Title)); st == "" {
647
+
return fmt.Errorf("title is empty after HTML sanitization")
648
+
}
649
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(issue.Body)); sb == "" {
650
+
return fmt.Errorf("body is empty after HTML sanitization")
651
+
}
652
+
653
+
tx, err := ddb.BeginTx(ctx, nil)
654
+
if err != nil {
655
+
l.Error("failed to begin transaction", "err", err)
656
+
return err
657
+
}
658
+
659
+
err = db.NewIssue(tx, &issue)
660
+
if err != nil {
661
+
l.Error("failed to create issue", "err", err)
662
+
return err
663
+
}
664
+
665
+
return nil
666
+
667
+
case models.CommitOperationUpdate:
668
+
raw := json.RawMessage(e.Commit.Record)
669
+
record := tangled.RepoIssue{}
670
+
err = json.Unmarshal(raw, &record)
671
+
if err != nil {
672
+
l.Error("invalid record", "err", err)
673
+
return err
674
+
}
675
+
676
+
body := ""
677
+
if record.Body != nil {
678
+
body = *record.Body
679
+
}
680
+
681
+
sanitizer := markup.NewSanitizer()
682
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(record.Title)); st == "" {
683
+
return fmt.Errorf("title is empty after HTML sanitization")
684
+
}
685
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
686
+
return fmt.Errorf("body is empty after HTML sanitization")
687
+
}
688
+
689
+
err = db.UpdateIssueByRkey(ddb, did, rkey, record.Title, body)
690
+
if err != nil {
691
+
l.Error("failed to update issue", "err", err)
692
+
return err
693
+
}
694
+
695
+
return nil
696
+
697
+
case models.CommitOperationDelete:
698
+
if err := db.DeleteIssueByRkey(ddb, did, rkey); err != nil {
699
+
l.Error("failed to delete", "err", err)
700
+
return fmt.Errorf("failed to delete issue record: %w", err)
701
+
}
702
+
703
+
return nil
704
+
}
705
+
706
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
707
+
}
708
+
709
+
func (i *Ingester) ingestIssueComment(e *models.Event) error {
710
+
did := e.Did
711
+
rkey := e.Commit.RKey
712
+
713
+
var err error
714
+
715
+
l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
716
+
l.Info("ingesting record")
717
+
718
+
ddb, ok := i.Db.Execer.(*db.DB)
719
+
if !ok {
720
+
return fmt.Errorf("failed to index issue comment record, invalid db cast")
721
+
}
722
+
723
+
switch e.Commit.Operation {
724
+
case models.CommitOperationCreate:
725
+
raw := json.RawMessage(e.Commit.Record)
726
+
record := tangled.RepoIssueComment{}
727
+
err = json.Unmarshal(raw, &record)
728
+
if err != nil {
729
+
l.Error("invalid record", "err", err)
730
+
return err
731
+
}
732
+
733
+
comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
734
+
if err != nil {
735
+
l.Error("failed to parse comment from record", "err", err)
736
+
return err
737
+
}
738
+
739
+
sanitizer := markup.NewSanitizer()
740
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(comment.Body)); sb == "" {
741
+
return fmt.Errorf("body is empty after HTML sanitization")
742
+
}
743
+
744
+
err = db.NewIssueComment(ddb, &comment)
745
+
if err != nil {
746
+
l.Error("failed to create issue comment", "err", err)
747
+
return err
748
+
}
749
+
750
+
return nil
751
+
752
+
case models.CommitOperationUpdate:
753
+
raw := json.RawMessage(e.Commit.Record)
754
+
record := tangled.RepoIssueComment{}
755
+
err = json.Unmarshal(raw, &record)
756
+
if err != nil {
757
+
l.Error("invalid record", "err", err)
758
+
return err
759
+
}
760
+
761
+
sanitizer := markup.NewSanitizer()
762
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(record.Body)); sb == "" {
763
+
return fmt.Errorf("body is empty after HTML sanitization")
764
+
}
765
+
766
+
err = db.UpdateCommentByRkey(ddb, did, rkey, record.Body)
767
+
if err != nil {
768
+
l.Error("failed to update issue comment", "err", err)
769
+
return err
770
+
}
771
+
772
+
return nil
773
+
774
+
case models.CommitOperationDelete:
775
+
if err := db.DeleteCommentByRkey(ddb, did, rkey); err != nil {
776
+
l.Error("failed to delete", "err", err)
777
+
return fmt.Errorf("failed to delete issue comment record: %w", err)
778
+
}
779
+
780
+
return nil
781
+
}
782
+
783
+
return fmt.Errorf("unknown operation: %s", e.Commit.Operation)
784
+
}
+17
-10
appview/issues/issues.go
+17
-10
appview/issues/issues.go
···
7
7
"net/http"
8
8
"slices"
9
9
"strconv"
10
+
"strings"
10
11
"time"
11
12
12
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
20
21
"tangled.sh/tangled.sh/core/appview/notify"
21
22
"tangled.sh/tangled.sh/core/appview/oauth"
22
23
"tangled.sh/tangled.sh/core/appview/pages"
24
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
23
25
"tangled.sh/tangled.sh/core/appview/pagination"
24
26
"tangled.sh/tangled.sh/core/appview/reporesolver"
25
27
"tangled.sh/tangled.sh/core/idresolver"
···
276
278
}
277
279
278
280
createdAt := time.Now().Format(time.RFC3339)
279
-
commentIdInt64 := int64(commentId)
280
281
ownerDid := user.Did
281
282
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt)
282
283
if err != nil {
···
300
301
Val: &tangled.RepoIssueComment{
301
302
Repo: &atUri,
302
303
Issue: issueAt,
303
-
CommentId: &commentIdInt64,
304
304
Owner: &ownerDid,
305
305
Body: body,
306
306
CreatedAt: createdAt,
···
449
449
repoAt := record["repo"].(string)
450
450
issueAt := record["issue"].(string)
451
451
createdAt := record["createdAt"].(string)
452
-
commentIdInt64 := int64(commentIdInt)
453
452
454
453
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
455
454
Collection: tangled.RepoIssueCommentNSID,
···
460
459
Val: &tangled.RepoIssueComment{
461
460
Repo: &repoAt,
462
461
Issue: issueAt,
463
-
CommentId: &commentIdInt64,
464
462
Owner: &comment.OwnerDid,
465
463
Body: newBody,
466
464
CreatedAt: createdAt,
···
602
600
return
603
601
}
604
602
605
-
issues, err := db.GetIssues(rp.db, f.RepoAt(), isOpen, page)
603
+
issues, err := db.GetIssuesPaginated(rp.db, f.RepoAt(), isOpen, page)
606
604
if err != nil {
607
605
log.Println("failed to get issues", err)
608
606
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
642
640
return
643
641
}
644
642
643
+
sanitizer := markup.NewSanitizer()
644
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
645
+
rp.pages.Notice(w, "issues", "Title is empty after HTML sanitization")
646
+
return
647
+
}
648
+
if sb := strings.TrimSpace(sanitizer.SanitizeDefault(body)); sb == "" {
649
+
rp.pages.Notice(w, "issues", "Body is empty after HTML sanitization")
650
+
return
651
+
}
652
+
645
653
tx, err := rp.db.BeginTx(r.Context(), nil)
646
654
if err != nil {
647
655
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
···
675
683
Rkey: issue.Rkey,
676
684
Record: &lexutil.LexiconTypeDecoder{
677
685
Val: &tangled.RepoIssue{
678
-
Repo: atUri,
679
-
Title: title,
680
-
Body: &body,
681
-
Owner: user.Did,
682
-
IssueId: int64(issue.IssueId),
686
+
Repo: atUri,
687
+
Title: title,
688
+
Body: &body,
689
+
Owner: user.Did,
683
690
},
684
691
},
685
692
})
+6
-1
appview/pages/funcmap.go
+6
-1
appview/pages/funcmap.go
···
206
206
}
207
207
return v.Slice(0, min(n, v.Len())).Interface()
208
208
},
209
-
210
209
"markdown": func(text string) template.HTML {
211
210
p.rctx.RendererType = markup.RendererTypeDefault
212
211
htmlString := p.rctx.RenderMarkdown(text)
213
212
sanitized := p.rctx.SanitizeDefault(htmlString)
213
+
return template.HTML(sanitized)
214
+
},
215
+
"description": func(text string) template.HTML {
216
+
p.rctx.RendererType = markup.RendererTypeDefault
217
+
htmlString := p.rctx.RenderMarkdown(text)
218
+
sanitized := p.rctx.SanitizeDescription(htmlString)
214
219
return template.HTML(sanitized)
215
220
},
216
221
"isNil": func(t any) bool {
+5
-1
appview/pages/markup/markdown.go
+5
-1
appview/pages/markup/markdown.go
···
161
161
}
162
162
163
163
func (rctx *RenderContext) SanitizeDefault(html string) string {
164
-
return rctx.Sanitizer.defaultPolicy.Sanitize(html)
164
+
return rctx.Sanitizer.SanitizeDefault(html)
165
+
}
166
+
167
+
func (rctx *RenderContext) SanitizeDescription(html string) string {
168
+
return rctx.Sanitizer.SanitizeDescription(html)
165
169
}
166
170
167
171
type MarkdownTransformer struct {
+27
-2
appview/pages/markup/sanitizer.go
+27
-2
appview/pages/markup/sanitizer.go
···
11
11
)
12
12
13
13
type Sanitizer struct {
14
-
defaultPolicy *bluemonday.Policy
14
+
defaultPolicy *bluemonday.Policy
15
+
descriptionPolicy *bluemonday.Policy
15
16
}
16
17
17
18
func NewSanitizer() Sanitizer {
18
19
return Sanitizer{
19
-
defaultPolicy: defaultPolicy(),
20
+
defaultPolicy: defaultPolicy(),
21
+
descriptionPolicy: descriptionPolicy(),
20
22
}
23
+
}
24
+
25
+
func (s *Sanitizer) SanitizeDefault(html string) string {
26
+
return s.defaultPolicy.Sanitize(html)
27
+
}
28
+
func (s *Sanitizer) SanitizeDescription(html string) string {
29
+
return s.descriptionPolicy.Sanitize(html)
21
30
}
22
31
23
32
func defaultPolicy() *bluemonday.Policy {
···
90
99
91
100
return policy
92
101
}
102
+
103
+
func descriptionPolicy() *bluemonday.Policy {
104
+
policy := bluemonday.NewPolicy()
105
+
policy.AllowStandardURLs()
106
+
107
+
// allow italics and bold.
108
+
policy.AllowElements("i", "b", "em", "strong")
109
+
110
+
// allow code.
111
+
policy.AllowElements("code")
112
+
113
+
// allow links
114
+
policy.AllowAttrs("href", "target", "rel").OnElements("a")
115
+
116
+
return policy
117
+
}
+33
-25
appview/pages/pages.go
+33
-25
appview/pages/pages.go
···
299
299
type TimelineParams struct {
300
300
LoggedInUser *oauth.User
301
301
Timeline []db.TimelineEvent
302
+
Repos []db.Repo
302
303
}
303
304
304
305
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
305
-
return p.execute("timeline", w, params)
306
+
return p.execute("timeline/timeline", w, params)
306
307
}
307
308
308
309
type SettingsParams struct {
···
520
521
}
521
522
522
523
p.rctx.RepoInfo = params.RepoInfo
524
+
p.rctx.RepoInfo.Ref = params.Ref
523
525
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
524
526
525
527
if params.ReadmeFileName != "" {
···
673
675
}
674
676
}
675
677
676
-
if params.Lines < 5000 {
677
-
c := params.Contents
678
-
formatter := chromahtml.New(
679
-
chromahtml.InlineCode(false),
680
-
chromahtml.WithLineNumbers(true),
681
-
chromahtml.WithLinkableLineNumbers(true, "L"),
682
-
chromahtml.Standalone(false),
683
-
chromahtml.WithClasses(true),
684
-
)
678
+
c := params.Contents
679
+
formatter := chromahtml.New(
680
+
chromahtml.InlineCode(false),
681
+
chromahtml.WithLineNumbers(true),
682
+
chromahtml.WithLinkableLineNumbers(true, "L"),
683
+
chromahtml.Standalone(false),
684
+
chromahtml.WithClasses(true),
685
+
)
685
686
686
-
lexer := lexers.Get(filepath.Base(params.Path))
687
-
if lexer == nil {
688
-
lexer = lexers.Fallback
689
-
}
687
+
lexer := lexers.Get(filepath.Base(params.Path))
688
+
if lexer == nil {
689
+
lexer = lexers.Fallback
690
+
}
690
691
691
-
iterator, err := lexer.Tokenise(nil, c)
692
-
if err != nil {
693
-
return fmt.Errorf("chroma tokenize: %w", err)
694
-
}
695
-
696
-
var code bytes.Buffer
697
-
err = formatter.Format(&code, style, iterator)
698
-
if err != nil {
699
-
return fmt.Errorf("chroma format: %w", err)
700
-
}
692
+
iterator, err := lexer.Tokenise(nil, c)
693
+
if err != nil {
694
+
return fmt.Errorf("chroma tokenize: %w", err)
695
+
}
701
696
702
-
params.Contents = code.String()
697
+
var code bytes.Buffer
698
+
err = formatter.Format(&code, style, iterator)
699
+
if err != nil {
700
+
return fmt.Errorf("chroma format: %w", err)
703
701
}
704
702
703
+
params.Contents = code.String()
705
704
params.Active = "overview"
706
705
return p.executeRepo("repo/blob", w, params)
707
706
}
···
1158
1157
1159
1158
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
1160
1159
return p.execute("strings/dashboard", w, params)
1160
+
}
1161
+
1162
+
type StringTimelineParams struct {
1163
+
LoggedInUser *oauth.User
1164
+
Strings []db.String
1165
+
}
1166
+
1167
+
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
1168
+
return p.execute("strings/timeline", w, params)
1161
1169
}
1162
1170
1163
1171
type SingleStringParams struct {
+15
-20
appview/pages/templates/layouts/repobase.html
+15
-20
appview/pages/templates/layouts/repobase.html
···
20
20
</div>
21
21
22
22
<div class="flex items-center gap-2 z-auto">
23
+
<a
24
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
25
+
href="/{{ .RepoInfo.FullName }}/feed.atom"
26
+
>
27
+
{{ i "rss" "size-4" }}
28
+
</a>
23
29
{{ template "repo/fragments/repoStar" .RepoInfo }}
24
-
{{ if .RepoInfo.DisableFork }}
25
-
<button
26
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
27
-
disabled
28
-
title="Empty repositories cannot be forked"
29
-
>
30
-
{{ i "git-fork" "w-4 h-4" }}
31
-
fork
32
-
</button>
33
-
{{ else }}
34
-
<a
35
-
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
36
-
hx-boost="true"
37
-
href="/{{ .RepoInfo.FullName }}/fork"
38
-
>
39
-
{{ i "git-fork" "w-4 h-4" }}
40
-
fork
41
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
42
-
</a>
43
-
{{ end }}
30
+
<a
31
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group"
32
+
hx-boost="true"
33
+
href="/{{ .RepoInfo.FullName }}/fork"
34
+
>
35
+
{{ i "git-fork" "w-4 h-4" }}
36
+
fork
37
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
38
+
</a>
44
39
</div>
45
40
</div>
46
41
{{ template "repo/fragments/repoDescription" . }}
+1
-1
appview/pages/templates/repo/fragments/repoDescription.html
+1
-1
appview/pages/templates/repo/fragments/repoDescription.html
···
1
1
{{ define "repo/fragments/repoDescription" }}
2
2
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
3
{{ if .RepoInfo.Description }}
4
-
{{ .RepoInfo.Description }}
4
+
{{ .RepoInfo.Description | description }}
5
5
{{ else }}
6
6
<span class="italic">this repo has no description</span>
7
7
{{ end }}
+1
-1
appview/pages/templates/repo/issues/issue.html
+1
-1
appview/pages/templates/repo/issues/issue.html
+1
-1
appview/pages/templates/repo/issues/issues.html
+1
-1
appview/pages/templates/repo/issues/issues.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+1
-1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
+1
-1
appview/pages/templates/repo/pulls/pull.html
+1
-1
appview/pages/templates/repo/pulls/pull.html
···
122
122
{{ end }}
123
123
</div>
124
124
<div class="flex items-center">
125
-
<span>{{ .Title }}</span>
125
+
<span>{{ .Title | description }}</span>
126
126
{{ if gt (len .Body) 0 }}
127
127
<button
128
128
class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
+1
-1
appview/pages/templates/repo/pulls/pulls.html
+1
-1
appview/pages/templates/repo/pulls/pulls.html
+3
-2
appview/pages/templates/strings/fragments/form.html
+3
-2
appview/pages/templates/strings/fragments/form.html
···
13
13
type="text"
14
14
id="filename"
15
15
name="filename"
16
-
placeholder="Filename with extension"
16
+
placeholder="Filename"
17
17
required
18
18
value="{{ .String.Filename }}"
19
19
class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded"
···
31
31
name="content"
32
32
id="content-textarea"
33
33
wrap="off"
34
-
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
34
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono"
35
35
rows="20"
36
+
spellcheck="false"
36
37
placeholder="Paste your string here!"
37
38
required>{{ .String.Contents }}</textarea>
38
39
<div class="flex justify-between items-center">
+2
-2
appview/pages/templates/strings/string.html
+2
-2
appview/pages/templates/strings/string.html
···
35
35
title="Delete string"
36
36
hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/"
37
37
hx-swap="none"
38
-
hx-confirm="Are you sure you want to delete the gist `{{ .String.Filename }}`?"
38
+
hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?"
39
39
>
40
40
{{ i "trash-2" "size-4" }}
41
41
<span class="hidden md:inline">delete</span>
···
77
77
{{ end }}
78
78
</div>
79
79
</div>
80
-
<div class="overflow-auto relative">
80
+
<div class="overflow-x-auto overflow-y-hidden relative">
81
81
{{ if .ShowRendered }}
82
82
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
83
83
{{ else }}
+65
appview/pages/templates/strings/timeline.html
+65
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 }}
10
+
11
+
{{ define "timeline" }}
12
+
<div>
13
+
<div class="p-6">
14
+
<p class="text-xl font-bold dark:text-white">All strings</p>
15
+
</div>
16
+
17
+
<div class="flex flex-col gap-4">
18
+
{{ range $i, $s := .Strings }}
19
+
<div class="relative">
20
+
{{ if ne $i 0 }}
21
+
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
22
+
{{ end }}
23
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
24
+
{{ template "stringCard" $s }}
25
+
</div>
26
+
</div>
27
+
{{ end }}
28
+
</div>
29
+
</div>
30
+
{{ end }}
31
+
32
+
{{ define "stringCard" }}
33
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
34
+
<div class="font-medium dark:text-white flex gap-2 items-center">
35
+
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
36
+
</div>
37
+
{{ with .Description }}
38
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
39
+
{{ . }}
40
+
</div>
41
+
{{ end }}
42
+
43
+
{{ template "stringCardInfo" . }}
44
+
</div>
45
+
{{ end }}
46
+
47
+
{{ define "stringCardInfo" }}
48
+
{{ $stat := .Stats }}
49
+
{{ $resolved := resolve .Did.String }}
50
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
51
+
<a href="/strings/{{ $resolved }}" class="flex items-center">
52
+
{{ template "user/fragments/picHandle" $resolved }}
53
+
</a>
54
+
<span class="select-none [&:before]:content-['ยท']"></span>
55
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
56
+
<span class="select-none [&:before]:content-['ยท']"></span>
57
+
{{ with .Edited }}
58
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
59
+
{{ else }}
60
+
{{ template "repo/fragments/shortTimeAgo" .Created }}
61
+
{{ end }}
62
+
</div>
63
+
{{ end }}
64
+
65
+
+183
appview/pages/templates/timeline/timeline.html
+183
appview/pages/templates/timeline/timeline.html
···
1
+
{{ define "title" }}timeline{{ 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
+
{{ 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 text-sm">
175
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
176
+
<span id="followers">{{ .Followers }} followers</span>
177
+
<span class="select-none after:content-['ยท']"></span>
178
+
<span id="following">{{ .Following }} following</span>
179
+
</div>
180
+
{{ end }}
181
+
</div>
182
+
</div>
183
+
{{ end }}
-162
appview/pages/templates/timeline.html
-162
appview/pages/templates/timeline.html
···
1
-
{{ define "title" }}timeline{{ 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="see what's tangling" />
8
-
{{ end }}
9
-
10
-
{{ define "topbar" }}
11
-
{{ template "layouts/topbar" $ }}
12
-
{{ end }}
13
-
14
-
{{ define "content" }}
15
-
{{ with .LoggedInUser }}
16
-
{{ block "timeline" $ }}{{ end }}
17
-
{{ else }}
18
-
{{ block "hero" $ }}{{ end }}
19
-
{{ block "timeline" $ }}{{ end }}
20
-
{{ end }}
21
-
{{ end }}
22
-
23
-
{{ define "hero" }}
24
-
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
25
-
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
26
-
27
-
<p class="text-lg">
28
-
tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>.
29
-
</p>
30
-
<p class="text-lg">
31
-
we envision a place where developers have complete ownership of their
32
-
code, open source communities can freely self-govern and most
33
-
importantly, coding can be social and fun again.
34
-
</p>
35
-
36
-
<div class="flex gap-6 items-center">
37
-
<a href="/signup" class="no-underline hover:no-underline ">
38
-
<button class="btn-create flex gap-2 px-4 items-center">
39
-
join now {{ i "arrow-right" "size-4" }}
40
-
</button>
41
-
</a>
42
-
</div>
43
-
</div>
44
-
{{ end }}
45
-
46
-
{{ define "timeline" }}
47
-
<div>
48
-
<div class="p-6">
49
-
<p class="text-xl font-bold dark:text-white">Timeline</p>
50
-
</div>
51
-
52
-
<div class="flex flex-col gap-4">
53
-
{{ range $i, $e := .Timeline }}
54
-
<div class="relative">
55
-
{{ if ne $i 0 }}
56
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
57
-
{{ end }}
58
-
{{ with $e }}
59
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
60
-
{{ if .Repo }}
61
-
{{ block "repoEvent" (list $ .Repo .Source) }} {{ end }}
62
-
{{ else if .Star }}
63
-
{{ block "starEvent" (list $ .Star) }} {{ end }}
64
-
{{ else if .Follow }}
65
-
{{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }}
66
-
{{ end }}
67
-
</div>
68
-
{{ end }}
69
-
</div>
70
-
{{ end }}
71
-
</div>
72
-
</div>
73
-
{{ end }}
74
-
75
-
{{ define "repoEvent" }}
76
-
{{ $root := index . 0 }}
77
-
{{ $repo := index . 1 }}
78
-
{{ $source := index . 2 }}
79
-
{{ $userHandle := resolve $repo.Did }}
80
-
<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">
81
-
{{ template "user/fragments/picHandleLink" $repo.Did }}
82
-
{{ with $source }}
83
-
{{ $sourceDid := resolve .Did }}
84
-
forked
85
-
<a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline">
86
-
{{ $sourceDid }}/{{ .Name }}
87
-
</a>
88
-
to
89
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a>
90
-
{{ else }}
91
-
created
92
-
<a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">
93
-
{{ $repo.Name }}
94
-
</a>
95
-
{{ end }}
96
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span>
97
-
</div>
98
-
{{ with $repo }}
99
-
{{ template "user/fragments/repoCard" (list $root . true) }}
100
-
{{ end }}
101
-
{{ end }}
102
-
103
-
{{ define "starEvent" }}
104
-
{{ $root := index . 0 }}
105
-
{{ $star := index . 1 }}
106
-
{{ with $star }}
107
-
{{ $starrerHandle := resolve .StarredByDid }}
108
-
{{ $repoOwnerHandle := resolve .Repo.Did }}
109
-
<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">
110
-
{{ template "user/fragments/picHandleLink" $starrerHandle }}
111
-
starred
112
-
<a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">
113
-
{{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }}
114
-
</a>
115
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span>
116
-
</div>
117
-
{{ with .Repo }}
118
-
{{ template "user/fragments/repoCard" (list $root . true) }}
119
-
{{ end }}
120
-
{{ end }}
121
-
{{ end }}
122
-
123
-
124
-
{{ define "followEvent" }}
125
-
{{ $root := index . 0 }}
126
-
{{ $follow := index . 1 }}
127
-
{{ $profile := index . 2 }}
128
-
{{ $stat := index . 3 }}
129
-
130
-
{{ $userHandle := resolve $follow.UserDid }}
131
-
{{ $subjectHandle := resolve $follow.SubjectDid }}
132
-
<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">
133
-
{{ template "user/fragments/picHandleLink" $userHandle }}
134
-
followed
135
-
{{ template "user/fragments/picHandleLink" $subjectHandle }}
136
-
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
137
-
</div>
138
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
139
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
140
-
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
141
-
</div>
142
-
143
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
144
-
<a href="/{{ $subjectHandle }}">
145
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
146
-
</a>
147
-
{{ with $profile }}
148
-
{{ with .Description }}
149
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
150
-
{{ end }}
151
-
{{ end }}
152
-
{{ with $stat }}
153
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
154
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
155
-
<span id="followers">{{ .Followers }} followers</span>
156
-
<span class="select-none after:content-['ยท']"></span>
157
-
<span id="following">{{ .Following }} following</span>
158
-
</div>
159
-
{{ end }}
160
-
</div>
161
-
</div>
162
-
{{ end }}
+5
-5
appview/pages/templates/user/fragments/repoCard.html
+5
-5
appview/pages/templates/user/fragments/repoCard.html
···
4
4
{{ $fullName := index . 2 }}
5
5
6
6
{{ with $repo }}
7
-
<div class="py-4 px-6 gap-2 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800">
7
+
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
8
8
<div class="font-medium dark:text-white flex items-center">
9
9
{{ if .Source }}
10
10
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
···
14
14
15
15
{{ $repoOwner := resolve .Did }}
16
16
{{- if $fullName -}}
17
-
<a href="/{{ $repoOwner }}/{{ .Name }}">{{ $repoOwner }}/{{ .Name }}</a>
17
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
18
18
{{- else -}}
19
-
<a href="/{{ $repoOwner }}/{{ .Name }}">{{ .Name }}</a>
19
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
20
20
{{- end -}}
21
21
</div>
22
22
{{ with .Description }}
23
-
<div class="text-gray-600 dark:text-gray-300 text-sm">
24
-
{{ . }}
23
+
<div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
24
+
{{ . | description }}
25
25
</div>
26
26
{{ end }}
27
27
+6
appview/pulls/pulls.go
+6
appview/pulls/pulls.go
···
19
19
"tangled.sh/tangled.sh/core/appview/notify"
20
20
"tangled.sh/tangled.sh/core/appview/oauth"
21
21
"tangled.sh/tangled.sh/core/appview/pages"
22
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
23
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
24
"tangled.sh/tangled.sh/core/idresolver"
24
25
"tangled.sh/tangled.sh/core/knotclient"
···
737
738
if isPatchBased && !patchutil.IsFormatPatch(patch) {
738
739
if title == "" {
739
740
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
741
+
return
742
+
}
743
+
sanitizer := markup.NewSanitizer()
744
+
if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" {
745
+
s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
740
746
return
741
747
}
742
748
}
+165
appview/repo/feed.go
+165
appview/repo/feed.go
···
1
+
package repo
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"log"
7
+
"net/http"
8
+
"slices"
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"
15
+
"github.com/gorilla/feeds"
16
+
)
17
+
18
+
func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) {
19
+
const feedLimitPerType = 100
20
+
21
+
pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt()))
22
+
if err != nil {
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
+
}
30
+
31
+
feed := &feeds.Feed{
32
+
Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()),
33
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"},
34
+
Items: make([]*feeds.Item, 0),
35
+
Updated: time.UnixMilli(0),
36
+
}
37
+
38
+
for _, pull := range pulls {
39
+
items, err := rp.createPullItems(ctx, pull, f)
40
+
if err != nil {
41
+
return nil, err
42
+
}
43
+
feed.Items = append(feed.Items, items...)
44
+
}
45
+
46
+
for _, issue := range issues {
47
+
item, err := rp.createIssueItem(ctx, issue, f)
48
+
if err != nil {
49
+
return nil, err
50
+
}
51
+
feed.Items = append(feed.Items, item)
52
+
}
53
+
54
+
slices.SortFunc(feed.Items, func(a, b *feeds.Item) int {
55
+
if a.Created.After(b.Created) {
56
+
return -1
57
+
}
58
+
return 1
59
+
})
60
+
61
+
if len(feed.Items) > 0 {
62
+
feed.Updated = feed.Items[0].Created
63
+
}
64
+
65
+
return feed, nil
66
+
}
67
+
68
+
func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
69
+
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
70
+
if err != nil {
71
+
return nil, err
72
+
}
73
+
74
+
var items []*feeds.Item
75
+
76
+
state := rp.getPullState(pull)
77
+
description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo())
78
+
79
+
mainItem := &feeds.Item{
80
+
Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
81
+
Description: description,
82
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)},
83
+
Created: pull.Created,
84
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
85
+
}
86
+
items = append(items, mainItem)
87
+
88
+
for _, round := range pull.Submissions {
89
+
if round == nil || round.RoundNumber == 0 {
90
+
continue
91
+
}
92
+
93
+
roundItem := &feeds.Item{
94
+
Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber),
95
+
Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()),
96
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)},
97
+
Created: round.Created,
98
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
99
+
}
100
+
items = append(items, roundItem)
101
+
}
102
+
103
+
return items, nil
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
+
}
111
+
112
+
state := "closed"
113
+
if issue.Open {
114
+
state = "opened"
115
+
}
116
+
117
+
return &feeds.Item{
118
+
Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
119
+
Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()),
120
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)},
121
+
Created: issue.Created,
122
+
Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
123
+
}, nil
124
+
}
125
+
126
+
func (rp *Repo) getPullState(pull *db.Pull) string {
127
+
if pull.State == db.PullOpen {
128
+
return "opened"
129
+
}
130
+
return pull.State.String()
131
+
}
132
+
133
+
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string {
134
+
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
135
+
136
+
if pull.State == db.PullMerged {
137
+
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
138
+
}
139
+
140
+
return fmt.Sprintf("%s in %s", base, repoName)
141
+
}
142
+
143
+
func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) {
144
+
f, err := rp.repoResolver.Resolve(r)
145
+
if err != nil {
146
+
log.Println("failed to fully resolve repo:", err)
147
+
return
148
+
}
149
+
150
+
feed, err := rp.getRepoFeed(r.Context(), f)
151
+
if err != nil {
152
+
log.Println("failed to get repo feed:", err)
153
+
rp.pages.Error500(w)
154
+
return
155
+
}
156
+
157
+
atom, err := feed.ToAtom()
158
+
if err != nil {
159
+
rp.pages.Error500(w)
160
+
return
161
+
}
162
+
163
+
w.Header().Set("content-type", "application/atom+xml")
164
+
w.Write([]byte(atom))
165
+
}
+12
-9
appview/repo/index.go
+12
-9
appview/repo/index.go
···
24
24
25
25
func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) {
26
26
ref := chi.URLParam(r, "ref")
27
+
27
28
f, err := rp.repoResolver.Resolve(r)
28
29
if err != nil {
29
30
log.Println("failed to fully resolve repo", err)
···
118
119
119
120
var forkInfo *types.ForkInfo
120
121
if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
121
-
forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient)
122
+
forkInfo, err = getForkInfo(repoInfo, rp, f, result.Ref, user, signedClient)
122
123
if err != nil {
123
124
log.Printf("Failed to fetch fork information: %v", err)
124
125
return
···
126
127
}
127
128
128
129
// TODO: a bit dirty
129
-
languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "")
130
+
languageInfo, err := rp.getLanguageInfo(f, signedClient, result.Ref, ref == "")
130
131
if err != nil {
131
132
log.Printf("failed to compute language percentages: %s", err)
132
133
// non-fatal
···
161
162
func (rp *Repo) getLanguageInfo(
162
163
f *reporesolver.ResolvedRepo,
163
164
signedClient *knotclient.SignedClient,
165
+
currentRef string,
164
166
isDefaultRef bool,
165
167
) ([]types.RepoLanguageDetails, error) {
166
168
// first attempt to fetch from db
167
169
langs, err := db.GetRepoLanguages(
168
170
rp.db,
169
171
db.FilterEq("repo_at", f.RepoAt()),
170
-
db.FilterEq("ref", f.Ref),
172
+
db.FilterEq("ref", currentRef),
171
173
)
172
174
173
175
if err != nil || langs == nil {
174
176
// non-fatal, fetch langs from ks
175
-
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, f.Ref)
177
+
ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, currentRef)
176
178
if err != nil {
177
179
return nil, err
178
180
}
···
183
185
for l, s := range ls.Languages {
184
186
langs = append(langs, db.RepoLanguage{
185
187
RepoAt: f.RepoAt(),
186
-
Ref: f.Ref,
188
+
Ref: currentRef,
187
189
IsDefaultRef: isDefaultRef,
188
190
Language: l,
189
191
Bytes: s,
···
234
236
repoInfo repoinfo.RepoInfo,
235
237
rp *Repo,
236
238
f *reporesolver.ResolvedRepo,
239
+
currentRef string,
237
240
user *oauth.User,
238
241
signedClient *knotclient.SignedClient,
239
242
) (*types.ForkInfo, error) {
···
264
267
}
265
268
266
269
if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
267
-
return branch.Name == f.Ref
270
+
return branch.Name == currentRef
268
271
}) {
269
272
forkInfo.Status = types.MissingBranch
270
273
return &forkInfo, nil
271
274
}
272
275
273
-
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
276
+
newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, currentRef, currentRef)
274
277
if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
275
278
log.Printf("failed to update tracking branch: %s", err)
276
279
return nil, err
277
280
}
278
281
279
-
hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
282
+
hiddenRef := fmt.Sprintf("hidden/%s/%s", currentRef, currentRef)
280
283
281
284
var status types.AncestorCheckResponse
282
-
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, f.Ref, hiddenRef)
285
+
forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, currentRef, hiddenRef)
283
286
if err != nil {
284
287
log.Printf("failed to check if fork is ahead/behind: %s", err)
285
288
return nil, err
+25
-3
appview/repo/repo.go
+25
-3
appview/repo/repo.go
···
612
612
if !rp.config.Core.Dev {
613
613
protocol = "https"
614
614
}
615
+
615
616
blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.Repo.Name, ref, filePath)
616
-
resp, err := http.Get(blobURL)
617
+
618
+
req, err := http.NewRequest("GET", blobURL, nil)
617
619
if err != nil {
618
-
log.Println("failed to reach knotserver:", err)
620
+
log.Println("failed to create request", err)
621
+
return
622
+
}
623
+
624
+
// forward the If-None-Match header
625
+
if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
626
+
req.Header.Set("If-None-Match", clientETag)
627
+
}
628
+
629
+
client := &http.Client{}
630
+
resp, err := client.Do(req)
631
+
if err != nil {
632
+
log.Println("failed to reach knotserver", err)
619
633
rp.pages.Error503(w)
620
634
return
621
635
}
622
636
defer resp.Body.Close()
637
+
638
+
// forward 304 not modified
639
+
if resp.StatusCode == http.StatusNotModified {
640
+
w.WriteHeader(http.StatusNotModified)
641
+
return
642
+
}
623
643
624
644
if resp.StatusCode != http.StatusOK {
625
645
log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
···
1315
1335
}
1316
1336
1317
1337
func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1338
+
ref := chi.URLParam(r, "ref")
1339
+
1318
1340
user := rp.oauth.GetUser(r)
1319
1341
f, err := rp.repoResolver.Resolve(r)
1320
1342
if err != nil {
···
1345
1367
forkName := fmt.Sprintf("%s", f.Name)
1346
1368
forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)
1347
1369
1348
-
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1370
+
_, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, ref)
1349
1371
if err != nil {
1350
1372
rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1351
1373
return
+1
appview/repo/router.go
+1
appview/repo/router.go
+17
-51
appview/reporesolver/resolver.go
+17
-51
appview/reporesolver/resolver.go
···
7
7
"fmt"
8
8
"log"
9
9
"net/http"
10
-
"net/url"
11
10
"path"
11
+
"regexp"
12
12
"strings"
13
13
14
14
"github.com/bluesky-social/indigo/atproto/identity"
···
20
20
"tangled.sh/tangled.sh/core/appview/pages"
21
21
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
22
22
"tangled.sh/tangled.sh/core/idresolver"
23
-
"tangled.sh/tangled.sh/core/knotclient"
24
23
"tangled.sh/tangled.sh/core/rbac"
25
24
)
26
25
27
26
type ResolvedRepo struct {
28
27
db.Repo
29
-
OwnerId identity.Identity
30
-
Ref string
31
-
CurrentDir string
28
+
OwnerId identity.Identity
29
+
CurrentDir string
30
+
Ref string
32
31
33
32
rr *RepoResolver
34
33
}
···
56
55
return nil, fmt.Errorf("malformed middleware")
57
56
}
58
57
58
+
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath()))
59
59
ref := chi.URLParam(r, "ref")
60
60
61
-
if ref == "" {
62
-
us, err := knotclient.NewUnsignedClient(repo.Knot, rr.config.Core.Dev)
63
-
if err != nil {
64
-
return nil, err
65
-
}
66
-
67
-
defaultBranch, err := us.DefaultBranch(id.DID.String(), repo.Name)
68
-
if err != nil {
69
-
return nil, err
70
-
}
71
-
72
-
ref = defaultBranch.Branch
73
-
}
74
-
75
-
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref))
76
-
77
61
return &ResolvedRepo{
78
62
Repo: *repo,
79
63
OwnerId: id,
80
-
Ref: ref,
81
64
CurrentDir: currentDir,
65
+
Ref: ref,
82
66
83
67
rr: rr,
84
68
}, nil
···
195
179
}
196
180
197
181
knot := f.Knot
198
-
var disableFork bool
199
-
us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev)
200
-
if err != nil {
201
-
log.Printf("failed to create unsigned client for %s: %v", knot, err)
202
-
} else {
203
-
result, err := us.Branches(f.OwnerDid(), f.Name)
204
-
if err != nil {
205
-
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.Name, err)
206
-
}
207
-
208
-
if len(result.Branches) == 0 {
209
-
disableFork = true
210
-
}
211
-
}
212
182
213
183
repoInfo := repoinfo.RepoInfo{
214
184
OwnerDid: f.OwnerDid(),
···
216
186
Name: f.Name,
217
187
RepoAt: repoAt,
218
188
Description: f.Description,
219
-
Ref: f.Ref,
220
189
IsStarred: isStarred,
221
190
Knot: knot,
222
191
Spindle: f.Spindle,
···
226
195
IssueCount: issueCount,
227
196
PullCount: pullCount,
228
197
},
229
-
DisableFork: disableFork,
230
-
CurrentDir: f.CurrentDir,
198
+
CurrentDir: f.CurrentDir,
199
+
Ref: f.Ref,
231
200
}
232
201
233
202
if sourceRepo != nil {
···
251
220
// after the ref. for example:
252
221
//
253
222
// /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/
254
-
func extractPathAfterRef(fullPath, ref string) string {
223
+
func extractPathAfterRef(fullPath string) string {
255
224
fullPath = strings.TrimPrefix(fullPath, "/")
256
225
257
-
ref = url.PathEscape(ref)
226
+
// match blob/, tree/, or raw/ followed by any ref and then a slash
227
+
//
228
+
// captures everything after the final slash
229
+
pattern := `(?:blob|tree|raw)/[^/]+/(.*)$`
258
230
259
-
prefixes := []string{
260
-
fmt.Sprintf("blob/%s/", ref),
261
-
fmt.Sprintf("tree/%s/", ref),
262
-
fmt.Sprintf("raw/%s/", ref),
263
-
}
231
+
re := regexp.MustCompile(pattern)
232
+
matches := re.FindStringSubmatch(fullPath)
264
233
265
-
for _, prefix := range prefixes {
266
-
idx := strings.Index(fullPath, prefix)
267
-
if idx != -1 {
268
-
return fullPath[idx+len(prefix):]
269
-
}
234
+
if len(matches) > 1 {
235
+
return matches[1]
270
236
}
271
237
272
238
return ""
+9
-12
appview/state/git_http.go
+9
-12
appview/state/git_http.go
···
3
3
import (
4
4
"fmt"
5
5
"io"
6
+
"maps"
6
7
"net/http"
7
8
8
9
"github.com/bluesky-social/indigo/atproto/identity"
9
10
"github.com/go-chi/chi/v5"
11
+
"tangled.sh/tangled.sh/core/appview/db"
10
12
)
11
13
12
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
13
15
user := r.Context().Value("resolvedId").(identity.Identity)
14
-
knot := r.Context().Value("knot").(string)
15
-
repo := chi.URLParam(r, "repo")
16
+
repo := r.Context().Value("repo").(*db.Repo)
16
17
17
18
scheme := "https"
18
19
if s.config.Core.Dev {
19
20
scheme = "http"
20
21
}
21
22
22
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
23
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
23
24
s.proxyRequest(w, r, targetURL)
24
25
25
26
}
···
30
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
31
32
return
32
33
}
33
-
knot := r.Context().Value("knot").(string)
34
-
repo := chi.URLParam(r, "repo")
34
+
repo := r.Context().Value("repo").(*db.Repo)
35
35
36
36
scheme := "https"
37
37
if s.config.Core.Dev {
38
38
scheme = "http"
39
39
}
40
40
41
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
41
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
42
42
s.proxyRequest(w, r, targetURL)
43
43
}
44
44
···
48
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
49
return
50
50
}
51
-
knot := r.Context().Value("knot").(string)
52
-
repo := chi.URLParam(r, "repo")
51
+
repo := r.Context().Value("repo").(*db.Repo)
53
52
54
53
scheme := "https"
55
54
if s.config.Core.Dev {
56
55
scheme = "http"
57
56
}
58
57
59
-
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
58
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
60
59
s.proxyRequest(w, r, targetURL)
61
60
}
62
61
···
85
84
defer resp.Body.Close()
86
85
87
86
// Copy response headers
88
-
for k, v := range resp.Header {
89
-
w.Header()[k] = v
90
-
}
87
+
maps.Copy(w.Header(), resp.Header)
91
88
92
89
// Set response status code
93
90
w.WriteHeader(resp.StatusCode)
+95
-82
appview/state/profile.go
+95
-82
appview/state/profile.go
···
89
89
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
90
90
}
91
91
92
-
var didsToResolve []string
93
-
for _, r := range collaboratingRepos {
94
-
didsToResolve = append(didsToResolve, r.Did)
95
-
}
96
-
for _, byMonth := range timeline.ByMonth {
97
-
for _, pe := range byMonth.PullEvents.Items {
98
-
didsToResolve = append(didsToResolve, pe.Repo.Did)
99
-
}
100
-
for _, ie := range byMonth.IssueEvents.Items {
101
-
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
102
-
}
103
-
for _, re := range byMonth.RepoEvents {
104
-
didsToResolve = append(didsToResolve, re.Repo.Did)
105
-
if re.Source != nil {
106
-
didsToResolve = append(didsToResolve, re.Source.Did)
107
-
}
108
-
}
109
-
}
110
-
111
92
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
112
93
if err != nil {
113
94
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
···
194
175
})
195
176
}
196
177
197
-
func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed {
178
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
198
179
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
199
180
if !ok {
200
181
s.pages.Error404(w)
201
-
return nil
182
+
return
202
183
}
203
184
204
-
feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String())
185
+
feed, err := s.getProfileFeed(r.Context(), &ident)
205
186
if err != nil {
206
187
s.pages.Error500(w)
207
-
return nil
188
+
return
208
189
}
209
190
210
-
return feed
211
-
}
212
-
213
-
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
214
-
feed := s.feedFromRequest(w, r)
215
191
if feed == nil {
216
192
return
217
193
}
···
226
202
w.Write([]byte(atom))
227
203
}
228
204
229
-
func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) {
230
-
timeline, err := db.MakeProfileTimeline(s.db, did)
205
+
func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
206
+
timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
231
207
if err != nil {
232
208
return nil, err
233
209
}
234
210
235
211
author := &feeds.Author{
236
-
Name: fmt.Sprintf("@%s", handle),
212
+
Name: fmt.Sprintf("@%s", id.Handle),
237
213
}
238
-
feed := &feeds.Feed{
239
-
Title: fmt.Sprintf("timeline feed for %s", author.Name),
240
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"},
214
+
215
+
feed := feeds.Feed{
216
+
Title: fmt.Sprintf("%s's timeline", author.Name),
217
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
241
218
Items: make([]*feeds.Item, 0),
242
219
Updated: time.UnixMilli(0),
243
220
Author: author,
244
221
}
222
+
245
223
for _, byMonth := range timeline.ByMonth {
246
-
for _, pull := range byMonth.PullEvents.Items {
247
-
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
248
-
if err != nil {
249
-
return nil, err
250
-
}
251
-
feed.Items = append(feed.Items, &feeds.Item{
252
-
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
253
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
254
-
Created: pull.Created,
255
-
Author: author,
256
-
})
257
-
for _, submission := range pull.Submissions {
258
-
feed.Items = append(feed.Items, &feeds.Item{
259
-
Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name),
260
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
261
-
Created: submission.Created,
262
-
Author: author,
263
-
})
264
-
}
224
+
if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
225
+
return nil, err
265
226
}
266
-
for _, issue := range byMonth.IssueEvents.Items {
267
-
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
268
-
if err != nil {
269
-
return nil, err
270
-
}
271
-
feed.Items = append(feed.Items, &feeds.Item{
272
-
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
273
-
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"},
274
-
Created: issue.Created,
275
-
Author: author,
276
-
})
227
+
if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
228
+
return nil, err
277
229
}
278
-
for _, repo := range byMonth.RepoEvents {
279
-
var title string
280
-
if repo.Source != nil {
281
-
id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
282
-
if err != nil {
283
-
return nil, err
284
-
}
285
-
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name)
286
-
} else {
287
-
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
288
-
}
289
-
feed.Items = append(feed.Items, &feeds.Item{
290
-
Title: title,
291
-
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"},
292
-
Created: repo.Repo.Created,
293
-
Author: author,
294
-
})
230
+
if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
231
+
return nil, err
295
232
}
296
233
}
234
+
297
235
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
298
236
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
299
237
})
238
+
300
239
if len(feed.Items) > 0 {
301
240
feed.Updated = feed.Items[0].Created
302
241
}
303
242
304
-
return feed, nil
243
+
return &feed, nil
244
+
}
245
+
246
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
247
+
for _, pull := range pulls {
248
+
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
249
+
if err != nil {
250
+
return err
251
+
}
252
+
253
+
// Add pull request creation item
254
+
feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
255
+
}
256
+
return nil
257
+
}
258
+
259
+
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
260
+
for _, issue := range issues {
261
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
262
+
if err != nil {
263
+
return err
264
+
}
265
+
266
+
feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
267
+
}
268
+
return nil
269
+
}
270
+
271
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
272
+
for _, repo := range repos {
273
+
item, err := s.createRepoItem(ctx, repo, author)
274
+
if err != nil {
275
+
return err
276
+
}
277
+
feed.Items = append(feed.Items, item)
278
+
}
279
+
return nil
280
+
}
281
+
282
+
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
283
+
return &feeds.Item{
284
+
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
285
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
286
+
Created: pull.Created,
287
+
Author: author,
288
+
}
289
+
}
290
+
291
+
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
292
+
return &feeds.Item{
293
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
294
+
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"},
295
+
Created: issue.Created,
296
+
Author: author,
297
+
}
298
+
}
299
+
300
+
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
301
+
var title string
302
+
if repo.Source != nil {
303
+
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
304
+
if err != nil {
305
+
return nil, err
306
+
}
307
+
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
308
+
} else {
309
+
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
310
+
}
311
+
312
+
return &feeds.Item{
313
+
Title: title,
314
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix
315
+
Created: repo.Repo.Created,
316
+
Author: author,
317
+
}, nil
305
318
}
306
319
307
320
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+5
-2
appview/state/router.go
+5
-2
appview/state/router.go
···
35
35
router.Get("/favicon.svg", s.Favicon)
36
36
router.Get("/favicon.ico", s.Favicon)
37
37
38
+
userRouter := s.UserRouter(&middleware)
39
+
standardRouter := s.StandardRouter(&middleware)
40
+
38
41
router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
39
42
pat := chi.URLParam(r, "*")
40
43
if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") {
41
-
s.UserRouter(&middleware).ServeHTTP(w, r)
44
+
userRouter.ServeHTTP(w, r)
42
45
} else {
43
46
// Check if the first path element is a valid handle without '@' or a flattened DID
44
47
pathParts := strings.SplitN(pat, "/", 2)
···
61
64
return
62
65
}
63
66
}
64
-
s.StandardRouter(&middleware).ServeHTTP(w, r)
67
+
standardRouter.ServeHTTP(w, r)
65
68
}
66
69
})
67
70
+10
appview/state/state.go
+10
appview/state/state.go
···
94
94
tangled.SpindleMemberNSID,
95
95
tangled.SpindleNSID,
96
96
tangled.StringNSID,
97
+
tangled.RepoIssueNSID,
98
+
tangled.RepoIssueCommentNSID,
97
99
},
98
100
nil,
99
101
slog.Default(),
···
193
195
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
194
196
}
195
197
198
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
199
+
if err != nil {
200
+
log.Println(err)
201
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
202
+
return
203
+
}
204
+
196
205
s.pages.Timeline(w, pages.TimelineParams{
197
206
LoggedInUser: user,
198
207
Timeline: timeline,
208
+
Repos: repos,
199
209
})
200
210
}
201
211
+23
-12
appview/strings/strings.go
+23
-12
appview/strings/strings.go
···
7
7
"path"
8
8
"slices"
9
9
"strconv"
10
-
"strings"
11
10
"time"
12
11
13
12
"tangled.sh/tangled.sh/core/api/tangled"
···
44
43
r := chi.NewRouter()
45
44
46
45
r.
46
+
Get("/", s.timeline)
47
+
48
+
r.
47
49
With(mw.ResolveIdent()).
48
50
Route("/{user}", func(r chi.Router) {
49
51
r.Get("/", s.dashboard)
···
70
72
return r
71
73
}
72
74
75
+
func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) {
76
+
l := s.Logger.With("handler", "timeline")
77
+
78
+
strings, err := db.GetStrings(s.Db, 50)
79
+
if err != nil {
80
+
l.Error("failed to fetch string", "err", err)
81
+
w.WriteHeader(http.StatusInternalServerError)
82
+
return
83
+
}
84
+
85
+
s.Pages.StringsTimeline(w, pages.StringTimelineParams{
86
+
LoggedInUser: s.OAuth.GetUser(r),
87
+
Strings: strings,
88
+
})
89
+
}
90
+
73
91
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
74
92
l := s.Logger.With("handler", "contents")
75
93
···
91
109
92
110
strings, err := db.GetStrings(
93
111
s.Db,
112
+
0,
94
113
db.FilterEq("did", id.DID),
95
114
db.FilterEq("rkey", rkey),
96
115
)
···
154
173
155
174
all, err := db.GetStrings(
156
175
s.Db,
176
+
0,
157
177
db.FilterEq("did", id.DID),
158
178
)
159
179
if err != nil {
···
225
245
// get the string currently being edited
226
246
all, err := db.GetStrings(
227
247
s.Db,
248
+
0,
228
249
db.FilterEq("did", id.DID),
229
250
db.FilterEq("rkey", rkey),
230
251
)
···
266
287
fail("Empty filename.", nil)
267
288
return
268
289
}
269
-
if !strings.Contains(filename, ".") {
270
-
// TODO: make this a htmx form validation
271
-
fail("No extension provided for filename.", nil)
272
-
return
273
-
}
274
290
275
291
content := r.FormValue("content")
276
292
if content == "" {
···
353
369
fail("Empty filename.", nil)
354
370
return
355
371
}
356
-
if !strings.Contains(filename, ".") {
357
-
// TODO: make this a htmx form validation
358
-
fail("No extension provided for filename.", nil)
359
-
return
360
-
}
361
372
362
373
content := r.FormValue("content")
363
374
if content == "" {
···
434
445
}
435
446
436
447
if user.Did != id.DID.String() {
437
-
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
448
+
fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
438
449
return
439
450
}
440
451
+1
-1
input.css
+1
-1
input.css
+8
-10
knotserver/git/fork.go
+8
-10
knotserver/git/fork.go
···
10
10
)
11
11
12
12
func Fork(repoPath, source string) error {
13
-
_, err := git.PlainClone(repoPath, true, &git.CloneOptions{
14
-
URL: source,
15
-
SingleBranch: false,
16
-
})
17
-
18
-
if err != nil {
13
+
cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath)
14
+
if err := cloneCmd.Run(); err != nil {
19
15
return fmt.Errorf("failed to bare clone repository: %w", err)
20
16
}
21
17
22
-
err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run()
23
-
if err != nil {
18
+
configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden")
19
+
if err := configureCmd.Run(); err != nil {
24
20
return fmt.Errorf("failed to configure hidden refs: %w", err)
25
21
}
26
22
27
23
return nil
28
24
}
29
25
30
-
func (g *GitRepo) Sync(branch string) error {
26
+
func (g *GitRepo) Sync() error {
27
+
branch := g.h.String()
28
+
31
29
fetchOpts := &git.FetchOptions{
32
30
RefSpecs: []config.RefSpec{
33
-
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)),
31
+
config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master
34
32
},
35
33
}
36
34
+1
knotserver/git.go
+1
knotserver/git.go
···
129
129
// If the appview gave us the repository owner's handle we can attempt to
130
130
// construct the correct ssh url.
131
131
ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
132
+
ownerHandle = strings.TrimPrefix(ownerHandle, "@")
132
133
if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
133
134
hostname := d.c.Server.Hostname
134
135
if strings.Contains(hostname, ":") {
+2
-2
knotserver/handler.go
+2
-2
knotserver/handler.go
···
142
142
r.Delete("/", h.RemoveRepo)
143
143
r.Route("/fork", func(r chi.Router) {
144
144
r.Post("/", h.RepoFork)
145
-
r.Post("/sync/{branch}", h.RepoForkSync)
146
-
r.Get("/sync/{branch}", h.RepoForkAheadBehind)
145
+
r.Post("/sync/*", h.RepoForkSync)
146
+
r.Get("/sync/*", h.RepoForkAheadBehind)
147
147
})
148
148
})
149
149
+16
-11
knotserver/routes.go
+16
-11
knotserver/routes.go
···
286
286
mimeType = "image/svg+xml"
287
287
}
288
288
289
+
contentHash := sha256.Sum256(contents)
290
+
eTag := fmt.Sprintf("\"%x\"", contentHash)
291
+
289
292
// allow image, video, and text/plain files to be served directly
290
293
switch {
291
-
case strings.HasPrefix(mimeType, "image/"):
292
-
// allowed
293
-
case strings.HasPrefix(mimeType, "video/"):
294
-
// allowed
294
+
case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
295
+
if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
296
+
w.WriteHeader(http.StatusNotModified)
297
+
return
298
+
}
299
+
w.Header().Set("ETag", eTag)
300
+
295
301
case strings.HasPrefix(mimeType, "text/plain"):
296
-
// allowed
302
+
w.Header().Set("Cache-Control", "public, no-cache")
303
+
297
304
default:
298
305
l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
299
306
writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden)
300
307
return
301
308
}
302
309
303
-
w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours
304
-
w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents)))
305
310
w.Header().Set("Content-Type", mimeType)
306
311
w.Write(contents)
307
312
}
···
710
715
}
711
716
712
717
func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) {
713
-
l := h.l.With("handler", "RepoForkSync")
718
+
l := h.l.With("handler", "RepoForkAheadBehind")
714
719
715
720
data := struct {
716
721
Did string `json:"did"`
···
845
850
name = filepath.Base(source)
846
851
}
847
852
848
-
branch := chi.URLParam(r, "branch")
853
+
branch := chi.URLParam(r, "*")
849
854
branch, _ = url.PathUnescape(branch)
850
855
851
856
relativeRepoPath := filepath.Join(did, name)
852
857
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
853
858
854
-
gr, err := git.PlainOpen(repoPath)
859
+
gr, err := git.Open(repoPath, branch)
855
860
if err != nil {
856
861
log.Println(err)
857
862
notFound(w)
858
863
return
859
864
}
860
865
861
-
err = gr.Sync(branch)
866
+
err = gr.Sync()
862
867
if err != nil {
863
868
l.Error("error syncing repo fork", "error", err.Error())
864
869
writeError(w, err.Error(), http.StatusInternalServerError)
+1
-8
lexicons/issue/comment.json
+1
-8
lexicons/issue/comment.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"issue",
14
-
"body",
15
-
"createdAt"
16
-
],
12
+
"required": ["issue", "body", "createdAt"],
17
13
"properties": {
18
14
"issue": {
19
15
"type": "string",
···
22
18
"repo": {
23
19
"type": "string",
24
20
"format": "at-uri"
25
-
},
26
-
"commentId": {
27
-
"type": "integer"
28
21
},
29
22
"owner": {
30
23
"type": "string",
+1
-10
lexicons/issue/issue.json
+1
-10
lexicons/issue/issue.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"issueId",
15
-
"owner",
16
-
"title",
17
-
"createdAt"
18
-
],
12
+
"required": ["repo", "owner", "title", "createdAt"],
19
13
"properties": {
20
14
"repo": {
21
15
"type": "string",
22
16
"format": "at-uri"
23
-
},
24
-
"issueId": {
25
-
"type": "integer"
26
17
},
27
18
"owner": {
28
19
"type": "string",
+7
-6
spindle/engines/nixery/engine.go
+7
-6
spindle/engines/nixery/engine.go
···
195
195
io.Copy(os.Stdout, reader)
196
196
197
197
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
198
-
Image: addl.image,
199
-
Cmd: []string{"cat"},
200
-
OpenStdin: true, // so cat stays alive :3
201
-
Tty: false,
202
-
Hostname: "spindle",
198
+
Image: addl.image,
199
+
Cmd: []string{"cat"},
200
+
OpenStdin: true, // so cat stays alive :3
201
+
Tty: false,
202
+
Hostname: "spindle",
203
+
WorkingDir: workspaceDir,
203
204
// TODO(winter): investigate whether environment variables passed here
204
205
// get propagated to ContainerExec processes
205
206
}, &container.HostConfig{
···
304
305
envs.AddEnv(k, v)
305
306
}
306
307
envs.AddEnv("HOME", homeDir)
307
-
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
308
308
309
309
mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{
310
310
Cmd: []string{"bash", "-c", step.command},
311
311
AttachStdout: true,
312
312
AttachStderr: true,
313
+
Env: envs,
313
314
})
314
315
if err != nil {
315
316
return fmt.Errorf("creating exec: %w", err)