···14 command: |
15 mkdir -p appview/pages/static; touch appview/pages/static/x
1600000017 - name: run all tests
18 environment:
19 CGO_ENABLED: 1
···14 command: |
15 mkdir -p appview/pages/static; touch appview/pages/static/x
1617+ - name: run linter
18+ environment:
19+ CGO_ENABLED: 1
20+ command: |
21+ go vet -v ./...
22+23 - name: run all tests
24 environment:
25 CGO_ENABLED: 1
+72-26
api/tangled/cbor_gen.go
···5812 fieldCount--
5813 }
581400005815 if t.Source == nil {
5816 fieldCount--
5817 }
···5889 return err
5890 }
58915892- // t.Owner (string) (string)
5893- if len("owner") > 1000000 {
5894- return xerrors.Errorf("Value in field \"owner\" was too long")
5895- }
00000000058965897- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil {
5898- return err
5899- }
5900- if _, err := cw.WriteString(string("owner")); err != nil {
5901- return err
5902- }
0000059035904- if len(t.Owner) > 1000000 {
5905- return xerrors.Errorf("Value in field t.Owner was too long")
5906- }
00059075908- if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil {
5909- return err
5910- }
5911- if _, err := cw.WriteString(string(t.Owner)); err != nil {
5912- return err
5913 }
59145915 // t.Source (string) (string)
···61076108 t.LexiconTypeID = string(sval)
6109 }
6110- // t.Owner (string) (string)
6111- case "owner":
0000000000000000000000000061126113- {
6114- sval, err := cbg.ReadStringWithMax(cr, 1000000)
6115- if err != nil {
6116- return err
000006117 }
6118-6119- t.Owner = string(sval)
6120 }
6121 // t.Source (string) (string)
6122 case "source":
···5812 fieldCount--
5813 }
58145815+ if t.Labels == nil {
5816+ fieldCount--
5817+ }
5818+5819 if t.Source == nil {
5820 fieldCount--
5821 }
···5893 return err
5894 }
58955896+ // t.Labels ([]string) (slice)
5897+ if t.Labels != nil {
5898+5899+ if len("labels") > 1000000 {
5900+ return xerrors.Errorf("Value in field \"labels\" was too long")
5901+ }
5902+5903+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("labels"))); err != nil {
5904+ return err
5905+ }
5906+ if _, err := cw.WriteString(string("labels")); err != nil {
5907+ return err
5908+ }
59095910+ if len(t.Labels) > 8192 {
5911+ return xerrors.Errorf("Slice value in field t.Labels was too long")
5912+ }
5913+5914+ if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Labels))); err != nil {
5915+ return err
5916+ }
5917+ for _, v := range t.Labels {
5918+ if len(v) > 1000000 {
5919+ return xerrors.Errorf("Value in field v was too long")
5920+ }
59215922+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
5923+ return err
5924+ }
5925+ if _, err := cw.WriteString(string(v)); err != nil {
5926+ return err
5927+ }
59285929+ }
00005930 }
59315932 // t.Source (string) (string)
···61246125 t.LexiconTypeID = string(sval)
6126 }
6127+ // t.Labels ([]string) (slice)
6128+ case "labels":
6129+6130+ maj, extra, err = cr.ReadHeader()
6131+ if err != nil {
6132+ return err
6133+ }
6134+6135+ if extra > 8192 {
6136+ return fmt.Errorf("t.Labels: array too large (%d)", extra)
6137+ }
6138+6139+ if maj != cbg.MajArray {
6140+ return fmt.Errorf("expected cbor array")
6141+ }
6142+6143+ if extra > 0 {
6144+ t.Labels = make([]string, extra)
6145+ }
6146+6147+ for i := 0; i < int(extra); i++ {
6148+ {
6149+ var maj byte
6150+ var extra uint64
6151+ var err error
6152+ _ = maj
6153+ _ = extra
6154+ _ = err
61556156+ {
6157+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
6158+ if err != nil {
6159+ return err
6160+ }
6161+6162+ t.Labels[i] = string(sval)
6163+ }
6164+6165 }
006166 }
6167 // t.Source (string) (string)
6168 case "source":
+10
api/tangled/repotree.go
···31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32 // parent: The parent path in the tree
33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
0034 // ref: The git reference used
35 Ref string `json:"ref" cborgen:"ref"`
0000000036}
3738// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
···31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32 // parent: The parent path in the tree
33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34+ // readme: Readme for this file tree
35+ Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
36 // ref: The git reference used
37 Ref string `json:"ref" cborgen:"ref"`
38+}
39+40+// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
41+type RepoTree_Readme struct {
42+ // contents: Contents of the readme file
43+ Contents string `json:"contents" cborgen:"contents"`
44+ // filename: Name of the readme file
45+ Filename string `json:"filename" cborgen:"filename"`
46}
4748// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+3-2
api/tangled/tangledrepo.go
···22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
23 // knot: knot where the repo was created
24 Knot string `json:"knot" cborgen:"knot"`
0025 // name: name of the repo
26- Name string `json:"name" cborgen:"name"`
27- Owner string `json:"owner" cborgen:"owner"`
28 // source: source of the repo
29 Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
30 // spindle: CI runner to send jobs to and receive results from
···22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"`
23 // knot: knot where the repo was created
24 Knot string `json:"knot" cborgen:"knot"`
25+ // labels: List of labels that this repo subscribes to
26+ Labels []string `json:"labels,omitempty" cborgen:"labels,omitempty"`
27 // name: name of the repo
28+ Name string `json:"name" cborgen:"name"`
029 // source: source of the repo
30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31 // spindle: CI runner to send jobs to and receive results from
···6 "strings"
7 "time"
89- "github.com/bluesky-social/indigo/atproto/syntax"
10)
1112-type Spindle struct {
13- Id int
14- Owner syntax.DID
15- Instance string
16- Verified *time.Time
17- Created time.Time
18- NeedsUpgrade bool
19-}
20-21-type SpindleMember struct {
22- Id int
23- Did syntax.DID // owner of the record
24- Rkey string // rkey of the record
25- Instance string
26- Subject syntax.DID // the member being added
27- Created time.Time
28-}
29-30-func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) {
31- var spindles []Spindle
3233 var conditions []string
34 var args []any
···59 defer rows.Close()
6061 for rows.Next() {
62- var spindle Spindle
63 var createdAt string
64 var verified sql.NullString
65 var needsUpgrade int
···100}
101102// if there is an existing spindle with the same instance, this returns an error
103-func AddSpindle(e Execer, spindle Spindle) error {
104 _, err := e.Exec(
105 `insert into spindles (owner, instance) values (?, ?)`,
106 spindle.Owner,
···151 return err
152}
153154-func AddSpindleMember(e Execer, member SpindleMember) error {
155 _, err := e.Exec(
156 `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
157 member.Did,
···181 return err
182}
183184-func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) {
185- var members []SpindleMember
186187 var conditions []string
188 var args []any
···213 defer rows.Close()
214215 for rows.Next() {
216- var member SpindleMember
217 var createdAt string
218219 if err := rows.Scan(
···6 "strings"
7 "time"
89+ "tangled.org/core/appview/models"
10)
1112+func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
13+ var spindles []models.Spindle
0000000000000000001415 var conditions []string
16 var args []any
···41 defer rows.Close()
4243 for rows.Next() {
44+ var spindle models.Spindle
45 var createdAt string
46 var verified sql.NullString
47 var needsUpgrade int
···82}
8384// if there is an existing spindle with the same instance, this returns an error
85+func AddSpindle(e Execer, spindle models.Spindle) error {
86 _, err := e.Exec(
87 `insert into spindles (owner, instance) values (?, ?)`,
88 spindle.Owner,
···133 return err
134}
135136+func AddSpindleMember(e Execer, member models.SpindleMember) error {
137 _, err := e.Exec(
138 `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
139 member.Did,
···163 return err
164}
165166+func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
167+ var members []models.SpindleMember
168169 var conditions []string
170 var args []any
···195 defer rows.Close()
196197 for rows.Next() {
198+ var member models.SpindleMember
199 var createdAt string
200201 if err := rows.Scan(
+27-39
appview/db/star.go
···5 "errors"
6 "fmt"
7 "log"
08 "strings"
9 "time"
1011 "github.com/bluesky-social/indigo/atproto/syntax"
012)
1314-type Star struct {
15- StarredByDid string
16- RepoAt syntax.ATURI
17- Created time.Time
18- Rkey string
19-20- // optionally, populate this when querying for reverse mappings
21- Repo *Repo
22-}
23-24-func (star *Star) ResolveRepo(e Execer) error {
25- if star.Repo != nil {
26- return nil
27- }
28-29- repo, err := GetRepoByAtUri(e, star.RepoAt.String())
30- if err != nil {
31- return err
32- }
33-34- star.Repo = repo
35- return nil
36-}
37-38-func AddStar(e Execer, star *Star) error {
39 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
40 _, err := e.Exec(
41 query,
···47}
4849// Get a star record
50-func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
51 query := `
52 select starred_by_did, repo_at, created, rkey
53 from stars
54 where starred_by_did = ? and repo_at = ?`
55 row := e.QueryRow(query, starredByDid, repoAt)
5657- var star Star
58 var created string
59 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
60 if err != nil {
···152func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
153 return getStarStatuses(e, userDid, repoAts)
154}
155-func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
156 var conditions []string
157 var args []any
158 for _, filter := range filters {
···184 return nil, err
185 }
186187- starMap := make(map[string][]Star)
188 for rows.Next() {
189- var star Star
190 var created string
191 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
192 if err != nil {
···227 }
228 }
229230- var stars []Star
231 for _, s := range starMap {
232 stars = append(stars, s...)
233 }
0000000000234235 return stars, nil
236}
···259 return count, nil
260}
261262-func GetAllStars(e Execer, limit int) ([]Star, error) {
263- var stars []Star
264265 rows, err := e.Query(`
266 select
···283 defer rows.Close()
284285 for rows.Next() {
286- var star Star
287- var repo Repo
288 var starCreatedAt, repoCreatedAt string
289290 if err := rows.Scan(
···322}
323324// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
325-func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
326 // first, get the top repo URIs by star count from the last week
327 query := `
328 with recent_starred_repos as (
···366 }
367368 if len(repoUris) == 0 {
369- return []Repo{}, nil
370 }
371372 // get full repo data
···376 }
377378 // sort repos by the original trending order
379- repoMap := make(map[string]Repo)
380 for _, repo := range repos {
381 repoMap[repo.RepoAt().String()] = repo
382 }
383384- orderedRepos := make([]Repo, 0, len(repoUris))
385 for _, uri := range repoUris {
386 if repo, exists := repoMap[uri]; exists {
387 orderedRepos = append(orderedRepos, repo)
···5 "errors"
6 "fmt"
7 "log"
8+ "slices"
9 "strings"
10 "time"
1112 "github.com/bluesky-social/indigo/atproto/syntax"
13+ "tangled.org/core/appview/models"
14)
1516+func AddStar(e Execer, star *models.Star) error {
00000000000000000000000017 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
18 _, err := e.Exec(
19 query,
···25}
2627// Get a star record
28+func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
29 query := `
30 select starred_by_did, repo_at, created, rkey
31 from stars
32 where starred_by_did = ? and repo_at = ?`
33 row := e.QueryRow(query, starredByDid, repoAt)
3435+ var star models.Star
36 var created string
37 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
38 if err != nil {
···130func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
131 return getStarStatuses(e, userDid, repoAts)
132}
133+func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
134 var conditions []string
135 var args []any
136 for _, filter := range filters {
···162 return nil, err
163 }
164165+ starMap := make(map[string][]models.Star)
166 for rows.Next() {
167+ var star models.Star
168 var created string
169 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
170 if err != nil {
···205 }
206 }
207208+ var stars []models.Star
209 for _, s := range starMap {
210 stars = append(stars, s...)
211 }
212+213+ slices.SortFunc(stars, func(a, b models.Star) int {
214+ if a.Created.After(b.Created) {
215+ return -1
216+ }
217+ if b.Created.After(a.Created) {
218+ return 1
219+ }
220+ return 0
221+ })
222223 return stars, nil
224}
···247 return count, nil
248}
249250+func GetAllStars(e Execer, limit int) ([]models.Star, error) {
251+ var stars []models.Star
252253 rows, err := e.Query(`
254 select
···271 defer rows.Close()
272273 for rows.Next() {
274+ var star models.Star
275+ var repo models.Repo
276 var starCreatedAt, repoCreatedAt string
277278 if err := rows.Scan(
···310}
311312// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
313+func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
314 // first, get the top repo URIs by star count from the last week
315 query := `
316 with recent_starred_repos as (
···354 }
355356 if len(repoUris) == 0 {
357+ return []models.Repo{}, nil
358 }
359360 // get full repo data
···364 }
365366 // sort repos by the original trending order
367+ repoMap := make(map[string]models.Repo)
368 for _, repo := range repos {
369 repoMap[repo.RepoAt().String()] = repo
370 }
371372+ orderedRepos := make([]models.Repo, 0, len(repoUris))
373 for _, uri := range repoUris {
374 if repo, exists := repoMap[uri]; exists {
375 orderedRepos = append(orderedRepos, repo)
+5-110
appview/db/strings.go
···1package db
23import (
4- "bytes"
5 "database/sql"
6 "errors"
7 "fmt"
8- "io"
9 "strings"
10 "time"
11- "unicode/utf8"
1213- "github.com/bluesky-social/indigo/atproto/syntax"
14- "tangled.org/core/api/tangled"
15)
1617-type String struct {
18- Did syntax.DID
19- Rkey string
20-21- Filename string
22- Description string
23- Contents string
24- Created time.Time
25- Edited *time.Time
26-}
27-28-func (s *String) StringAt() syntax.ATURI {
29- return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30-}
31-32-type StringStats struct {
33- LineCount uint64
34- ByteCount uint64
35-}
36-37-func (s String) Stats() StringStats {
38- lineCount, err := countLines(strings.NewReader(s.Contents))
39- if err != nil {
40- // non-fatal
41- // TODO: log this?
42- }
43-44- return StringStats{
45- LineCount: uint64(lineCount),
46- ByteCount: uint64(len(s.Contents)),
47- }
48-}
49-50-func (s String) Validate() error {
51- var err error
52-53- if utf8.RuneCountInString(s.Filename) > 140 {
54- err = errors.Join(err, fmt.Errorf("filename too long"))
55- }
56-57- if utf8.RuneCountInString(s.Description) > 280 {
58- err = errors.Join(err, fmt.Errorf("description too long"))
59- }
60-61- if len(s.Contents) == 0 {
62- err = errors.Join(err, fmt.Errorf("contents is empty"))
63- }
64-65- return err
66-}
67-68-func (s *String) AsRecord() tangled.String {
69- return tangled.String{
70- Filename: s.Filename,
71- Description: s.Description,
72- Contents: s.Contents,
73- CreatedAt: s.Created.Format(time.RFC3339),
74- }
75-}
76-77-func StringFromRecord(did, rkey string, record tangled.String) String {
78- created, err := time.Parse(record.CreatedAt, time.RFC3339)
79- if err != nil {
80- created = time.Now()
81- }
82- return String{
83- Did: syntax.DID(did),
84- Rkey: rkey,
85- Filename: record.Filename,
86- Description: record.Description,
87- Contents: record.Contents,
88- Created: created,
89- }
90-}
91-92-func AddString(e Execer, s String) error {
93 _, err := e.Exec(
94 `insert into strings (
95 did,
···123 return err
124}
125126-func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
127- var all []String
128129 var conditions []string
130 var args []any
···167 defer rows.Close()
168169 for rows.Next() {
170- var s String
171 var createdAt string
172 var editedAt sql.NullString
173···248 _, err := e.Exec(query, args...)
249 return err
250}
251-252-func countLines(r io.Reader) (int, error) {
253- buf := make([]byte, 32*1024)
254- bufLen := 0
255- count := 0
256- nl := []byte{'\n'}
257-258- for {
259- c, err := r.Read(buf)
260- if c > 0 {
261- bufLen += c
262- }
263- count += bytes.Count(buf[:c], nl)
264-265- switch {
266- case err == io.EOF:
267- /* handle last line not having a newline at the end */
268- if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
269- count++
270- }
271- return count, nil
272- case err != nil:
273- return 0, err
274- }
275- }
276-}
···1package db
23import (
04 "database/sql"
5 "errors"
6 "fmt"
07 "strings"
8 "time"
0910+ "tangled.org/core/appview/models"
011)
1213+func AddString(e Execer, s models.String) error {
00000000000000000000000000000000000000000000000000000000000000000000000000014 _, err := e.Exec(
15 `insert into strings (
16 did,
···44 return err
45}
4647+func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
48+ var all []models.String
4950 var conditions []string
51 var args []any
···88 defer rows.Close()
8990 for rows.Next() {
91+ var s models.String
92 var createdAt string
93 var editedAt sql.NullString
94···169 _, err := e.Exec(query, args...)
170 return err
171}
00000000000000000000000000
+20-40
appview/db/timeline.go
···23import (
4 "sort"
5- "time"
67 "github.com/bluesky-social/indigo/atproto/syntax"
08)
910-type TimelineEvent struct {
11- *Repo
12- *Follow
13- *Star
14-15- EventAt time.Time
16-17- // optional: populate only if Repo is a fork
18- Source *Repo
19-20- // optional: populate only if event is Follow
21- *Profile
22- *FollowStats
23- *FollowStatus
24-25- // optional: populate only if event is Repo
26- IsStarred bool
27- StarCount int64
28-}
29-30// TODO: this gathers heterogenous events from different sources and aggregates
31// them in code; if we did this entirely in sql, we could order and limit and paginate easily
32-func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
33- var events []TimelineEvent
3435 repos, err := getTimelineRepos(e, limit, loggedInUserDid)
36 if err != nil {
···63 return events, nil
64}
6566-func fetchStarStatuses(e Execer, loggedInUserDid string, repos []Repo) (map[string]bool, error) {
67 if loggedInUserDid == "" {
68 return nil, nil
69 }
···76 return GetStarStatuses(e, loggedInUserDid, repoAts)
77}
7879-func getRepoStarInfo(repo *Repo, starStatuses map[string]bool) (bool, int64) {
80 var isStarred bool
81 if starStatuses != nil {
82 isStarred = starStatuses[repo.RepoAt().String()]
···90 return isStarred, starCount
91}
9293-func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
94 repos, err := GetRepos(e, limit)
95 if err != nil {
96 return nil, err
···104 }
105 }
106107- var origRepos []Repo
108 if args != nil {
109 origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
110 }
···112 return nil, err
113 }
114115- uriToRepo := make(map[string]Repo)
116 for _, r := range origRepos {
117 uriToRepo[r.RepoAt().String()] = r
118 }
···122 return nil, err
123 }
124125- var events []TimelineEvent
126 for _, r := range repos {
127- var source *Repo
128 if r.Source != "" {
129 if origRepo, ok := uriToRepo[r.Source]; ok {
130 source = &origRepo
···133134 isStarred, starCount := getRepoStarInfo(&r, starStatuses)
135136- events = append(events, TimelineEvent{
137 Repo: &r,
138 EventAt: r.Created,
139 Source: source,
···145 return events, nil
146}
147148-func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
149 stars, err := GetStars(e, limit)
150 if err != nil {
151 return nil, err
···161 }
162 stars = stars[:n]
163164- var repos []Repo
165 for _, s := range stars {
166 repos = append(repos, *s.Repo)
167 }
···171 return nil, err
172 }
173174- var events []TimelineEvent
175 for _, s := range stars {
176 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
177178- events = append(events, TimelineEvent{
179 Star: &s,
180 EventAt: s.Created,
181 IsStarred: isStarred,
···186 return events, nil
187}
188189-func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
190 follows, err := GetFollows(e, limit)
191 if err != nil {
192 return nil, err
···211 return nil, err
212 }
213214- var followStatuses map[string]FollowStatus
215 if loggedInUserDid != "" {
216 followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
217 if err != nil {
···219 }
220 }
221222- var events []TimelineEvent
223 for _, f := range follows {
224 profile, _ := profiles[f.SubjectDid]
225 followStatMap, _ := followStatMap[f.SubjectDid]
226227- followStatus := IsNotFollowing
228 if followStatuses != nil {
229 followStatus = followStatuses[f.SubjectDid]
230 }
231232- events = append(events, TimelineEvent{
233 Follow: &f,
234 Profile: profile,
235 FollowStats: &followStatMap,
···23import (
4 "sort"
056 "github.com/bluesky-social/indigo/atproto/syntax"
7+ "tangled.org/core/appview/models"
8)
90000000000000000000010// TODO: this gathers heterogenous events from different sources and aggregates
11// them in code; if we did this entirely in sql, we could order and limit and paginate easily
12+func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
13+ var events []models.TimelineEvent
1415 repos, err := getTimelineRepos(e, limit, loggedInUserDid)
16 if err != nil {
···43 return events, nil
44}
4546+func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) {
47 if loggedInUserDid == "" {
48 return nil, nil
49 }
···56 return GetStarStatuses(e, loggedInUserDid, repoAts)
57}
5859+func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) {
60 var isStarred bool
61 if starStatuses != nil {
62 isStarred = starStatuses[repo.RepoAt().String()]
···70 return isStarred, starCount
71}
7273+func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74 repos, err := GetRepos(e, limit)
75 if err != nil {
76 return nil, err
···84 }
85 }
8687+ var origRepos []models.Repo
88 if args != nil {
89 origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
90 }
···92 return nil, err
93 }
9495+ uriToRepo := make(map[string]models.Repo)
96 for _, r := range origRepos {
97 uriToRepo[r.RepoAt().String()] = r
98 }
···102 return nil, err
103 }
104105+ var events []models.TimelineEvent
106 for _, r := range repos {
107+ var source *models.Repo
108 if r.Source != "" {
109 if origRepo, ok := uriToRepo[r.Source]; ok {
110 source = &origRepo
···113114 isStarred, starCount := getRepoStarInfo(&r, starStatuses)
115116+ events = append(events, models.TimelineEvent{
117 Repo: &r,
118 EventAt: r.Created,
119 Source: source,
···125 return events, nil
126}
127128+func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129 stars, err := GetStars(e, limit)
130 if err != nil {
131 return nil, err
···141 }
142 stars = stars[:n]
143144+ var repos []models.Repo
145 for _, s := range stars {
146 repos = append(repos, *s.Repo)
147 }
···151 return nil, err
152 }
153154+ var events []models.TimelineEvent
155 for _, s := range stars {
156 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
157158+ events = append(events, models.TimelineEvent{
159 Star: &s,
160 EventAt: s.Created,
161 IsStarred: isStarred,
···166 return events, nil
167}
168169+func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170 follows, err := GetFollows(e, limit)
171 if err != nil {
172 return nil, err
···191 return nil, err
192 }
193194+ var followStatuses map[string]models.FollowStatus
195 if loggedInUserDid != "" {
196 followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
197 if err != nil {
···199 }
200 }
201202+ var events []models.TimelineEvent
203 for _, f := range follows {
204 profile, _ := profiles[f.SubjectDid]
205 followStatMap, _ := followStatMap[f.SubjectDid]
206207+ followStatus := models.IsNotFollowing
208 if followStatuses != nil {
209 followStatus = followStatuses[f.SubjectDid]
210 }
211212+ events = append(events, models.TimelineEvent{
213 Follow: &f,
214 Profile: profile,
215 FollowStats: &followStatMap,
+191-54
appview/ingester.go
···5 "encoding/json"
6 "fmt"
7 "log/slog"
0089 "time"
1011 "github.com/bluesky-social/indigo/atproto/syntax"
12- "github.com/bluesky-social/jetstream/pkg/models"
13 "github.com/go-git/go-git/v5/plumbing"
14 "github.com/ipfs/go-cid"
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/appview/config"
17 "tangled.org/core/appview/db"
018 "tangled.org/core/appview/serververify"
19 "tangled.org/core/appview/validator"
20 "tangled.org/core/idresolver"
···30 Validator *validator.Validator
31}
3233-type processFunc func(ctx context.Context, e *models.Event) error
3435func (i *Ingester) Ingest() processFunc {
36- return func(ctx context.Context, e *models.Event) error {
37 var err error
38 defer func() {
39 eventTime := e.TimeUS
···4546 l := i.Logger.With("kind", e.Kind)
47 switch e.Kind {
48- case models.EventKindAccount:
49 if !e.Account.Active && *e.Account.Status == "deactivated" {
50 err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
51 }
52- case models.EventKindIdentity:
53 err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
54- case models.EventKindCommit:
55 switch e.Commit.Collection {
56 case tangled.GraphFollowNSID:
57 err = i.ingestFollow(e)
···77 err = i.ingestIssue(ctx, e)
78 case tangled.RepoIssueCommentNSID:
79 err = i.ingestIssueComment(e)
000080 }
81 l = i.Logger.With("nsid", e.Commit.Collection)
82 }
···89 }
90}
9192-func (i *Ingester) ingestStar(e *models.Event) error {
93 var err error
94 did := e.Did
95···97 l = l.With("nsid", e.Commit.Collection)
9899 switch e.Commit.Operation {
100- case models.CommitOperationCreate, models.CommitOperationUpdate:
101 var subjectUri syntax.ATURI
102103 raw := json.RawMessage(e.Commit.Record)
···113 l.Error("invalid record", "err", err)
114 return err
115 }
116- err = db.AddStar(i.Db, &db.Star{
117 StarredByDid: did,
118 RepoAt: subjectUri,
119 Rkey: e.Commit.RKey,
120 })
121- case models.CommitOperationDelete:
122 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
123 }
124···129 return nil
130}
131132-func (i *Ingester) ingestFollow(e *models.Event) error {
133 var err error
134 did := e.Did
135···137 l = l.With("nsid", e.Commit.Collection)
138139 switch e.Commit.Operation {
140- case models.CommitOperationCreate, models.CommitOperationUpdate:
141 raw := json.RawMessage(e.Commit.Record)
142 record := tangled.GraphFollow{}
143 err = json.Unmarshal(raw, &record)
···146 return err
147 }
148149- err = db.AddFollow(i.Db, &db.Follow{
150 UserDid: did,
151 SubjectDid: record.Subject,
152 Rkey: e.Commit.RKey,
153 })
154- case models.CommitOperationDelete:
155 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
156 }
157···162 return nil
163}
164165-func (i *Ingester) ingestPublicKey(e *models.Event) error {
166 did := e.Did
167 var err error
168···170 l = l.With("nsid", e.Commit.Collection)
171172 switch e.Commit.Operation {
173- case models.CommitOperationCreate, models.CommitOperationUpdate:
174 l.Debug("processing add of pubkey")
175 raw := json.RawMessage(e.Commit.Record)
176 record := tangled.PublicKey{}
···183 name := record.Name
184 key := record.Key
185 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
186- case models.CommitOperationDelete:
187 l.Debug("processing delete of pubkey")
188 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
189 }
···195 return nil
196}
197198-func (i *Ingester) ingestArtifact(e *models.Event) error {
199 did := e.Did
200 var err error
201···203 l = l.With("nsid", e.Commit.Collection)
204205 switch e.Commit.Operation {
206- case models.CommitOperationCreate, models.CommitOperationUpdate:
207 raw := json.RawMessage(e.Commit.Record)
208 record := tangled.RepoArtifact{}
209 err = json.Unmarshal(raw, &record)
···232 createdAt = time.Now()
233 }
234235- artifact := db.Artifact{
236 Did: did,
237 Rkey: e.Commit.RKey,
238 RepoAt: repoAt,
···245 }
246247 err = db.AddArtifact(i.Db, artifact)
248- case models.CommitOperationDelete:
249 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
250 }
251···256 return nil
257}
258259-func (i *Ingester) ingestProfile(e *models.Event) error {
260 did := e.Did
261 var err error
262···268 }
269270 switch e.Commit.Operation {
271- case models.CommitOperationCreate, models.CommitOperationUpdate:
272 raw := json.RawMessage(e.Commit.Record)
273 record := tangled.ActorProfile{}
274 err = json.Unmarshal(raw, &record)
···296 }
297 }
298299- var stats [2]db.VanityStat
300 for i, s := range record.Stats {
301 if i < 2 {
302- stats[i].Kind = db.VanityStatKind(s)
303 }
304 }
305···310 }
311 }
312313- profile := db.Profile{
314 Did: did,
315 Description: description,
316 IncludeBluesky: includeBluesky,
···336 }
337338 err = db.UpsertProfile(tx, &profile)
339- case models.CommitOperationDelete:
340 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
341 }
342···347 return nil
348}
349350-func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
351 did := e.Did
352 var err error
353···355 l = l.With("nsid", e.Commit.Collection)
356357 switch e.Commit.Operation {
358- case models.CommitOperationCreate:
359 raw := json.RawMessage(e.Commit.Record)
360 record := tangled.SpindleMember{}
361 err = json.Unmarshal(raw, &record)
···384 return fmt.Errorf("failed to index profile record, invalid db cast")
385 }
386387- err = db.AddSpindleMember(ddb, db.SpindleMember{
388 Did: syntax.DID(did),
389 Rkey: e.Commit.RKey,
390 Instance: record.Instance,
···400 }
401402 l.Info("added spindle member")
403- case models.CommitOperationDelete:
404 rkey := e.Commit.RKey
405406 ddb, ok := i.Db.Execer.(*db.DB)
···453 return nil
454}
455456-func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
457 did := e.Did
458 var err error
459···461 l = l.With("nsid", e.Commit.Collection)
462463 switch e.Commit.Operation {
464- case models.CommitOperationCreate:
465 raw := json.RawMessage(e.Commit.Record)
466 record := tangled.Spindle{}
467 err = json.Unmarshal(raw, &record)
···477 return fmt.Errorf("failed to index profile record, invalid db cast")
478 }
479480- err := db.AddSpindle(ddb, db.Spindle{
481 Owner: syntax.DID(did),
482 Instance: instance,
483 })
···499500 return nil
501502- case models.CommitOperationDelete:
503 instance := e.Commit.RKey
504505 ddb, ok := i.Db.Execer.(*db.DB)
···567 return nil
568}
569570-func (i *Ingester) ingestString(e *models.Event) error {
571 did := e.Did
572 rkey := e.Commit.RKey
573···582 }
583584 switch e.Commit.Operation {
585- case models.CommitOperationCreate, models.CommitOperationUpdate:
586 raw := json.RawMessage(e.Commit.Record)
587 record := tangled.String{}
588 err = json.Unmarshal(raw, &record)
···591 return err
592 }
593594- string := db.StringFromRecord(did, rkey, record)
595596- if err = string.Validate(); err != nil {
597 l.Error("invalid record", "err", err)
598 return err
599 }
···605606 return nil
607608- case models.CommitOperationDelete:
609 if err := db.DeleteString(
610 ddb,
611 db.FilterEq("did", did),
···621 return nil
622}
623624-func (i *Ingester) ingestKnotMember(e *models.Event) error {
625 did := e.Did
626 var err error
627···629 l = l.With("nsid", e.Commit.Collection)
630631 switch e.Commit.Operation {
632- case models.CommitOperationCreate:
633 raw := json.RawMessage(e.Commit.Record)
634 record := tangled.KnotMember{}
635 err = json.Unmarshal(raw, &record)
···659 }
660661 l.Info("added knot member")
662- case models.CommitOperationDelete:
663 // we don't store knot members in a table (like we do for spindle)
664 // and we can't remove this just yet. possibly fixed if we switch
665 // to either:
···673 return nil
674}
675676-func (i *Ingester) ingestKnot(e *models.Event) error {
677 did := e.Did
678 var err error
679···681 l = l.With("nsid", e.Commit.Collection)
682683 switch e.Commit.Operation {
684- case models.CommitOperationCreate:
685 raw := json.RawMessage(e.Commit.Record)
686 record := tangled.Knot{}
687 err = json.Unmarshal(raw, &record)
···716717 return nil
718719- case models.CommitOperationDelete:
720 domain := e.Commit.RKey
721722 ddb, ok := i.Db.Execer.(*db.DB)
···776777 return nil
778}
779-func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
780 did := e.Did
781 rkey := e.Commit.RKey
782···791 }
792793 switch e.Commit.Operation {
794- case models.CommitOperationCreate, models.CommitOperationUpdate:
795 raw := json.RawMessage(e.Commit.Record)
796 record := tangled.RepoIssue{}
797 err = json.Unmarshal(raw, &record)
···800 return err
801 }
802803- issue := db.IssueFromRecord(did, rkey, record)
804805 if err := i.Validator.ValidateIssue(&issue); err != nil {
806 return fmt.Errorf("failed to validate issue: %w", err)
···827828 return nil
829830- case models.CommitOperationDelete:
831 if err := db.DeleteIssues(
832 ddb,
833 db.FilterEq("did", did),
···843 return nil
844}
845846-func (i *Ingester) ingestIssueComment(e *models.Event) error {
847 did := e.Did
848 rkey := e.Commit.RKey
849···858 }
859860 switch e.Commit.Operation {
861- case models.CommitOperationCreate, models.CommitOperationUpdate:
862 raw := json.RawMessage(e.Commit.Record)
863 record := tangled.RepoIssueComment{}
864 err = json.Unmarshal(raw, &record)
···866 return fmt.Errorf("invalid record: %w", err)
867 }
868869- comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record)
870 if err != nil {
871 return fmt.Errorf("failed to parse comment from record: %w", err)
872 }
···882883 return nil
884885- case models.CommitOperationDelete:
886 if err := db.DeleteIssueComments(
887 ddb,
888 db.FilterEq("did", did),
···896897 return nil
898}
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···5 "encoding/json"
6 "fmt"
7 "log/slog"
8+ "maps"
9+ "slices"
1011 "time"
1213 "github.com/bluesky-social/indigo/atproto/syntax"
14+ jmodels "github.com/bluesky-social/jetstream/pkg/models"
15 "github.com/go-git/go-git/v5/plumbing"
16 "github.com/ipfs/go-cid"
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/appview/config"
19 "tangled.org/core/appview/db"
20+ "tangled.org/core/appview/models"
21 "tangled.org/core/appview/serververify"
22 "tangled.org/core/appview/validator"
23 "tangled.org/core/idresolver"
···33 Validator *validator.Validator
34}
3536+type processFunc func(ctx context.Context, e *jmodels.Event) error
3738func (i *Ingester) Ingest() processFunc {
39+ return func(ctx context.Context, e *jmodels.Event) error {
40 var err error
41 defer func() {
42 eventTime := e.TimeUS
···4849 l := i.Logger.With("kind", e.Kind)
50 switch e.Kind {
51+ case jmodels.EventKindAccount:
52 if !e.Account.Active && *e.Account.Status == "deactivated" {
53 err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
54 }
55+ case jmodels.EventKindIdentity:
56 err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
57+ case jmodels.EventKindCommit:
58 switch e.Commit.Collection {
59 case tangled.GraphFollowNSID:
60 err = i.ingestFollow(e)
···80 err = i.ingestIssue(ctx, e)
81 case tangled.RepoIssueCommentNSID:
82 err = i.ingestIssueComment(e)
83+ case tangled.LabelDefinitionNSID:
84+ err = i.ingestLabelDefinition(e)
85+ case tangled.LabelOpNSID:
86+ err = i.ingestLabelOp(e)
87 }
88 l = i.Logger.With("nsid", e.Commit.Collection)
89 }
···96 }
97}
9899+func (i *Ingester) ingestStar(e *jmodels.Event) error {
100 var err error
101 did := e.Did
102···104 l = l.With("nsid", e.Commit.Collection)
105106 switch e.Commit.Operation {
107+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
108 var subjectUri syntax.ATURI
109110 raw := json.RawMessage(e.Commit.Record)
···120 l.Error("invalid record", "err", err)
121 return err
122 }
123+ err = db.AddStar(i.Db, &models.Star{
124 StarredByDid: did,
125 RepoAt: subjectUri,
126 Rkey: e.Commit.RKey,
127 })
128+ case jmodels.CommitOperationDelete:
129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
130 }
131···136 return nil
137}
138139+func (i *Ingester) ingestFollow(e *jmodels.Event) error {
140 var err error
141 did := e.Did
142···144 l = l.With("nsid", e.Commit.Collection)
145146 switch e.Commit.Operation {
147+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
148 raw := json.RawMessage(e.Commit.Record)
149 record := tangled.GraphFollow{}
150 err = json.Unmarshal(raw, &record)
···153 return err
154 }
155156+ err = db.AddFollow(i.Db, &models.Follow{
157 UserDid: did,
158 SubjectDid: record.Subject,
159 Rkey: e.Commit.RKey,
160 })
161+ case jmodels.CommitOperationDelete:
162 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
163 }
164···169 return nil
170}
171172+func (i *Ingester) ingestPublicKey(e *jmodels.Event) error {
173 did := e.Did
174 var err error
175···177 l = l.With("nsid", e.Commit.Collection)
178179 switch e.Commit.Operation {
180+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
181 l.Debug("processing add of pubkey")
182 raw := json.RawMessage(e.Commit.Record)
183 record := tangled.PublicKey{}
···190 name := record.Name
191 key := record.Key
192 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
193+ case jmodels.CommitOperationDelete:
194 l.Debug("processing delete of pubkey")
195 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
196 }
···202 return nil
203}
204205+func (i *Ingester) ingestArtifact(e *jmodels.Event) error {
206 did := e.Did
207 var err error
208···210 l = l.With("nsid", e.Commit.Collection)
211212 switch e.Commit.Operation {
213+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
214 raw := json.RawMessage(e.Commit.Record)
215 record := tangled.RepoArtifact{}
216 err = json.Unmarshal(raw, &record)
···239 createdAt = time.Now()
240 }
241242+ artifact := models.Artifact{
243 Did: did,
244 Rkey: e.Commit.RKey,
245 RepoAt: repoAt,
···252 }
253254 err = db.AddArtifact(i.Db, artifact)
255+ case jmodels.CommitOperationDelete:
256 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
257 }
258···263 return nil
264}
265266+func (i *Ingester) ingestProfile(e *jmodels.Event) error {
267 did := e.Did
268 var err error
269···275 }
276277 switch e.Commit.Operation {
278+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
279 raw := json.RawMessage(e.Commit.Record)
280 record := tangled.ActorProfile{}
281 err = json.Unmarshal(raw, &record)
···303 }
304 }
305306+ var stats [2]models.VanityStat
307 for i, s := range record.Stats {
308 if i < 2 {
309+ stats[i].Kind = models.VanityStatKind(s)
310 }
311 }
312···317 }
318 }
319320+ profile := models.Profile{
321 Did: did,
322 Description: description,
323 IncludeBluesky: includeBluesky,
···343 }
344345 err = db.UpsertProfile(tx, &profile)
346+ case jmodels.CommitOperationDelete:
347 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
348 }
349···354 return nil
355}
356357+func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error {
358 did := e.Did
359 var err error
360···362 l = l.With("nsid", e.Commit.Collection)
363364 switch e.Commit.Operation {
365+ case jmodels.CommitOperationCreate:
366 raw := json.RawMessage(e.Commit.Record)
367 record := tangled.SpindleMember{}
368 err = json.Unmarshal(raw, &record)
···391 return fmt.Errorf("failed to index profile record, invalid db cast")
392 }
393394+ err = db.AddSpindleMember(ddb, models.SpindleMember{
395 Did: syntax.DID(did),
396 Rkey: e.Commit.RKey,
397 Instance: record.Instance,
···407 }
408409 l.Info("added spindle member")
410+ case jmodels.CommitOperationDelete:
411 rkey := e.Commit.RKey
412413 ddb, ok := i.Db.Execer.(*db.DB)
···460 return nil
461}
462463+func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error {
464 did := e.Did
465 var err error
466···468 l = l.With("nsid", e.Commit.Collection)
469470 switch e.Commit.Operation {
471+ case jmodels.CommitOperationCreate:
472 raw := json.RawMessage(e.Commit.Record)
473 record := tangled.Spindle{}
474 err = json.Unmarshal(raw, &record)
···484 return fmt.Errorf("failed to index profile record, invalid db cast")
485 }
486487+ err := db.AddSpindle(ddb, models.Spindle{
488 Owner: syntax.DID(did),
489 Instance: instance,
490 })
···506507 return nil
508509+ case jmodels.CommitOperationDelete:
510 instance := e.Commit.RKey
511512 ddb, ok := i.Db.Execer.(*db.DB)
···574 return nil
575}
576577+func (i *Ingester) ingestString(e *jmodels.Event) error {
578 did := e.Did
579 rkey := e.Commit.RKey
580···589 }
590591 switch e.Commit.Operation {
592+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
593 raw := json.RawMessage(e.Commit.Record)
594 record := tangled.String{}
595 err = json.Unmarshal(raw, &record)
···598 return err
599 }
600601+ string := models.StringFromRecord(did, rkey, record)
602603+ if err = i.Validator.ValidateString(&string); err != nil {
604 l.Error("invalid record", "err", err)
605 return err
606 }
···612613 return nil
614615+ case jmodels.CommitOperationDelete:
616 if err := db.DeleteString(
617 ddb,
618 db.FilterEq("did", did),
···628 return nil
629}
630631+func (i *Ingester) ingestKnotMember(e *jmodels.Event) error {
632 did := e.Did
633 var err error
634···636 l = l.With("nsid", e.Commit.Collection)
637638 switch e.Commit.Operation {
639+ case jmodels.CommitOperationCreate:
640 raw := json.RawMessage(e.Commit.Record)
641 record := tangled.KnotMember{}
642 err = json.Unmarshal(raw, &record)
···666 }
667668 l.Info("added knot member")
669+ case jmodels.CommitOperationDelete:
670 // we don't store knot members in a table (like we do for spindle)
671 // and we can't remove this just yet. possibly fixed if we switch
672 // to either:
···680 return nil
681}
682683+func (i *Ingester) ingestKnot(e *jmodels.Event) error {
684 did := e.Did
685 var err error
686···688 l = l.With("nsid", e.Commit.Collection)
689690 switch e.Commit.Operation {
691+ case jmodels.CommitOperationCreate:
692 raw := json.RawMessage(e.Commit.Record)
693 record := tangled.Knot{}
694 err = json.Unmarshal(raw, &record)
···723724 return nil
725726+ case jmodels.CommitOperationDelete:
727 domain := e.Commit.RKey
728729 ddb, ok := i.Db.Execer.(*db.DB)
···783784 return nil
785}
786+func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error {
787 did := e.Did
788 rkey := e.Commit.RKey
789···798 }
799800 switch e.Commit.Operation {
801+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
802 raw := json.RawMessage(e.Commit.Record)
803 record := tangled.RepoIssue{}
804 err = json.Unmarshal(raw, &record)
···807 return err
808 }
809810+ issue := models.IssueFromRecord(did, rkey, record)
811812 if err := i.Validator.ValidateIssue(&issue); err != nil {
813 return fmt.Errorf("failed to validate issue: %w", err)
···834835 return nil
836837+ case jmodels.CommitOperationDelete:
838 if err := db.DeleteIssues(
839 ddb,
840 db.FilterEq("did", did),
···850 return nil
851}
852853+func (i *Ingester) ingestIssueComment(e *jmodels.Event) error {
854 did := e.Did
855 rkey := e.Commit.RKey
856···865 }
866867 switch e.Commit.Operation {
868+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
869 raw := json.RawMessage(e.Commit.Record)
870 record := tangled.RepoIssueComment{}
871 err = json.Unmarshal(raw, &record)
···873 return fmt.Errorf("invalid record: %w", err)
874 }
875876+ comment, err := models.IssueCommentFromRecord(did, rkey, record)
877 if err != nil {
878 return fmt.Errorf("failed to parse comment from record: %w", err)
879 }
···889890 return nil
891892+ case jmodels.CommitOperationDelete:
893 if err := db.DeleteIssueComments(
894 ddb,
895 db.FilterEq("did", did),
···903904 return nil
905}
906+907+func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error {
908+ did := e.Did
909+ rkey := e.Commit.RKey
910+911+ var err error
912+913+ l := i.Logger.With("handler", "ingestLabelDefinition", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
914+ l.Info("ingesting record")
915+916+ ddb, ok := i.Db.Execer.(*db.DB)
917+ if !ok {
918+ return fmt.Errorf("failed to index label definition, invalid db cast")
919+ }
920+921+ switch e.Commit.Operation {
922+ case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
923+ raw := json.RawMessage(e.Commit.Record)
924+ record := tangled.LabelDefinition{}
925+ err = json.Unmarshal(raw, &record)
926+ if err != nil {
927+ return fmt.Errorf("invalid record: %w", err)
928+ }
929+930+ def, err := models.LabelDefinitionFromRecord(did, rkey, record)
931+ if err != nil {
932+ return fmt.Errorf("failed to parse labeldef from record: %w", err)
933+ }
934+935+ if err := i.Validator.ValidateLabelDefinition(def); err != nil {
936+ return fmt.Errorf("failed to validate labeldef: %w", err)
937+ }
938+939+ _, err = db.AddLabelDefinition(ddb, def)
940+ if err != nil {
941+ return fmt.Errorf("failed to create labeldef: %w", err)
942+ }
943+944+ return nil
945+946+ case jmodels.CommitOperationDelete:
947+ if err := db.DeleteLabelDefinition(
948+ ddb,
949+ db.FilterEq("did", did),
950+ db.FilterEq("rkey", rkey),
951+ ); err != nil {
952+ return fmt.Errorf("failed to delete labeldef record: %w", err)
953+ }
954+955+ return nil
956+ }
957+958+ return nil
959+}
960+961+func (i *Ingester) ingestLabelOp(e *jmodels.Event) error {
962+ did := e.Did
963+ rkey := e.Commit.RKey
964+965+ var err error
966+967+ l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
968+ l.Info("ingesting record")
969+970+ ddb, ok := i.Db.Execer.(*db.DB)
971+ if !ok {
972+ return fmt.Errorf("failed to index label op, invalid db cast")
973+ }
974+975+ switch e.Commit.Operation {
976+ case jmodels.CommitOperationCreate:
977+ raw := json.RawMessage(e.Commit.Record)
978+ record := tangled.LabelOp{}
979+ err = json.Unmarshal(raw, &record)
980+ if err != nil {
981+ return fmt.Errorf("invalid record: %w", err)
982+ }
983+984+ subject := syntax.ATURI(record.Subject)
985+ collection := subject.Collection()
986+987+ var repo *models.Repo
988+ switch collection {
989+ case tangled.RepoIssueNSID:
990+ i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
991+ if err != nil || len(i) != 1 {
992+ return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
993+ }
994+ repo = i[0].Repo
995+ default:
996+ return fmt.Errorf("unsupport label subject: %s", collection)
997+ }
998+999+ actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1000+ if err != nil {
1001+ return fmt.Errorf("failed to build label application ctx: %w", err)
1002+ }
1003+1004+ ops := models.LabelOpsFromRecord(did, rkey, record)
1005+1006+ for _, o := range ops {
1007+ def, ok := actx.Defs[o.OperandKey]
1008+ if !ok {
1009+ return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
1010+ }
1011+ if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
1012+ return fmt.Errorf("failed to validate labelop: %w", err)
1013+ }
1014+ }
1015+1016+ tx, err := ddb.Begin()
1017+ if err != nil {
1018+ return err
1019+ }
1020+ defer tx.Rollback()
1021+1022+ for _, o := range ops {
1023+ _, err = db.AddLabelOp(tx, &o)
1024+ if err != nil {
1025+ return fmt.Errorf("failed to add labelop: %w", err)
1026+ }
1027+ }
1028+1029+ if err = tx.Commit(); err != nil {
1030+ return err
1031+ }
1032+ }
1033+1034+ return nil
1035+}
+104-29
appview/issues/issues.go
···12 "time"
1314 comatproto "github.com/bluesky-social/indigo/api/atproto"
015 "github.com/bluesky-social/indigo/atproto/syntax"
16 lexutil "github.com/bluesky-social/indigo/lex/util"
17 "github.com/go-chi/chi/v5"
···19 "tangled.org/core/api/tangled"
20 "tangled.org/core/appview/config"
21 "tangled.org/core/appview/db"
022 "tangled.org/core/appview/notify"
23 "tangled.org/core/appview/oauth"
24 "tangled.org/core/appview/pages"
25 "tangled.org/core/appview/pagination"
26 "tangled.org/core/appview/reporesolver"
027 "tangled.org/core/appview/validator"
28- "tangled.org/core/appview/xrpcclient"
29 "tangled.org/core/idresolver"
30 tlog "tangled.org/core/log"
31 "tangled.org/core/tid"
···75 return
76 }
7778- issue, ok := r.Context().Value("issue").(*db.Issue)
79 if !ok {
80 l.Error("failed to get issue")
81 rp.pages.Error404(w)
82 return
83 }
8485- reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri())
86 if err != nil {
87 l.Error("failed to get issue reactions", "err", err)
88 }
8990- userReactions := map[db.ReactionKind]bool{}
91 if user != nil {
92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
93 }
94000000000000000095 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
96 LoggedInUser: user,
97 RepoInfo: f.RepoInfo(user),
98 Issue: issue,
99 CommentList: issue.CommentList(),
100- OrderedReactionKinds: db.OrderedReactionKinds,
101- Reactions: reactionCountMap,
102 UserReacted: userReactions,
0103 })
104}
105···112 return
113 }
114115- issue, ok := r.Context().Value("issue").(*db.Issue)
116 if !ok {
117 l.Error("failed to get issue")
118 rp.pages.Error404(w)
···148 return
149 }
150151- ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
152 if err != nil {
153 l.Error("failed to get record", "err", err)
154 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
155 return
156 }
157158- _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
159 Collection: tangled.RepoIssueNSID,
160 Repo: user.Did,
161 Rkey: newIssue.Rkey,
···208 return
209 }
210211- issue, ok := r.Context().Value("issue").(*db.Issue)
212 if !ok {
213 l.Error("failed to get issue")
214 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
···223 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
224 return
225 }
226- _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
227 Collection: tangled.RepoIssueNSID,
228 Repo: issue.Did,
229 Rkey: issue.Rkey,
···255 return
256 }
257258- issue, ok := r.Context().Value("issue").(*db.Issue)
259 if !ok {
260 l.Error("failed to get issue")
261 rp.pages.Error404(w)
···283 return
284 }
285000286 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
287 return
288 } else {
···301 return
302 }
303304- issue, ok := r.Context().Value("issue").(*db.Issue)
305 if !ok {
306 l.Error("failed to get issue")
307 rp.pages.Error404(w)
···345 return
346 }
347348- issue, ok := r.Context().Value("issue").(*db.Issue)
349 if !ok {
350 l.Error("failed to get issue")
351 rp.pages.Error404(w)
···364 replyTo = &replyToUri
365 }
366367- comment := db.IssueComment{
368 Did: user.Did,
369 Rkey: tid.TID(),
370 IssueAt: issue.AtUri().String(),
···387 }
388389 // create a record first
390- resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
391 Collection: tangled.RepoIssueCommentNSID,
392 Repo: comment.Did,
393 Rkey: comment.Rkey,
···416417 // reset atUri to make rollback a no-op
418 atUri = ""
00000419 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
420}
421···428 return
429 }
430431- issue, ok := r.Context().Value("issue").(*db.Issue)
432 if !ok {
433 l.Error("failed to get issue")
434 rp.pages.Error404(w)
···469 return
470 }
471472- issue, ok := r.Context().Value("issue").(*db.Issue)
473 if !ok {
474 l.Error("failed to get issue")
475 rp.pages.Error404(w)
···533 // rkey is optional, it was introduced later
534 if newComment.Rkey != "" {
535 // update the record on pds
536- ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
537 if err != nil {
538 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
540 return
541 }
542543- _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
544 Collection: tangled.RepoIssueCommentNSID,
545 Repo: user.Did,
546 Rkey: newComment.Rkey,
···573 return
574 }
575576- issue, ok := r.Context().Value("issue").(*db.Issue)
577 if !ok {
578 l.Error("failed to get issue")
579 rp.pages.Error404(w)
···614 return
615 }
616617- issue, ok := r.Context().Value("issue").(*db.Issue)
618 if !ok {
619 l.Error("failed to get issue")
620 rp.pages.Error404(w)
···655 return
656 }
657658- issue, ok := r.Context().Value("issue").(*db.Issue)
659 if !ok {
660 l.Error("failed to get issue")
661 rp.pages.Error404(w)
···707 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
708 return
709 }
710- _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
711 Collection: tangled.RepoIssueCommentNSID,
712 Repo: user.Did,
713 Rkey: comment.Rkey,
···733func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
734 params := r.URL.Query()
735 state := params.Get("state")
0000000000000000736 isOpen := true
737 switch state {
738 case "open":
···760 if isOpen {
761 openVal = 1
762 }
763- issues, err := db.GetIssuesPaginated(
0000000764 rp.db,
765 page,
0000766 db.FilterEq("repo_at", f.RepoAt()),
767 db.FilterEq("open", openVal),
768 )
0769 if err != nil {
770 log.Println("failed to get issues", err)
771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
772 return
773 }
7740000000000000000775 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
776 LoggedInUser: rp.oauth.GetUser(r),
777 RepoInfo: f.RepoInfo(user),
778 Issues: issues,
0779 FilteringByOpen: isOpen,
780 Page: page,
000781 })
782}
783···798 RepoInfo: f.RepoInfo(user),
799 })
800 case http.MethodPost:
801- issue := &db.Issue{
802 RepoAt: f.RepoAt(),
803 Rkey: tid.TID(),
804 Title: r.FormValue("title"),
···822 rp.pages.Notice(w, "issues", "Failed to create issue.")
823 return
824 }
825- resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
826 Collection: tangled.RepoIssueNSID,
827 Repo: user.Did,
828 Rkey: issue.Rkey,
···880// this is used to rollback changes made to the PDS
881//
882// it is a no-op if the provided ATURI is empty
883-func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
884 if aturi == "" {
885 return nil
886 }
···891 repo := parsed.Authority().String()
892 rkey := parsed.RecordKey().String()
893894- _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
895 Collection: collection,
896 Repo: repo,
897 Rkey: rkey,
···12 "time"
1314 comatproto "github.com/bluesky-social/indigo/api/atproto"
15+ atpclient "github.com/bluesky-social/indigo/atproto/client"
16 "github.com/bluesky-social/indigo/atproto/syntax"
17 lexutil "github.com/bluesky-social/indigo/lex/util"
18 "github.com/go-chi/chi/v5"
···20 "tangled.org/core/api/tangled"
21 "tangled.org/core/appview/config"
22 "tangled.org/core/appview/db"
23+ "tangled.org/core/appview/models"
24 "tangled.org/core/appview/notify"
25 "tangled.org/core/appview/oauth"
26 "tangled.org/core/appview/pages"
27 "tangled.org/core/appview/pagination"
28 "tangled.org/core/appview/reporesolver"
29+ "tangled.org/core/appview/search"
30 "tangled.org/core/appview/validator"
031 "tangled.org/core/idresolver"
32 tlog "tangled.org/core/log"
33 "tangled.org/core/tid"
···77 return
78 }
7980+ issue, ok := r.Context().Value("issue").(*models.Issue)
81 if !ok {
82 l.Error("failed to get issue")
83 rp.pages.Error404(w)
84 return
85 }
8687+ reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri())
88 if err != nil {
89 l.Error("failed to get issue reactions", "err", err)
90 }
9192+ userReactions := map[models.ReactionKind]bool{}
93 if user != nil {
94 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
95 }
9697+ labelDefs, err := db.GetLabelDefinitions(
98+ rp.db,
99+ db.FilterIn("at_uri", f.Repo.Labels),
100+ db.FilterContains("scope", tangled.RepoIssueNSID),
101+ )
102+ if err != nil {
103+ log.Println("failed to fetch labels", err)
104+ rp.pages.Error503(w)
105+ return
106+ }
107+108+ defs := make(map[string]*models.LabelDefinition)
109+ for _, l := range labelDefs {
110+ defs[l.AtUri().String()] = &l
111+ }
112+113 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
114 LoggedInUser: user,
115 RepoInfo: f.RepoInfo(user),
116 Issue: issue,
117 CommentList: issue.CommentList(),
118+ OrderedReactionKinds: models.OrderedReactionKinds,
119+ Reactions: reactionMap,
120 UserReacted: userReactions,
121+ LabelDefs: defs,
122 })
123}
124···131 return
132 }
133134+ issue, ok := r.Context().Value("issue").(*models.Issue)
135 if !ok {
136 l.Error("failed to get issue")
137 rp.pages.Error404(w)
···167 return
168 }
169170+ ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey)
171 if err != nil {
172 l.Error("failed to get record", "err", err)
173 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.")
174 return
175 }
176177+ _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
178 Collection: tangled.RepoIssueNSID,
179 Repo: user.Did,
180 Rkey: newIssue.Rkey,
···227 return
228 }
229230+ issue, ok := r.Context().Value("issue").(*models.Issue)
231 if !ok {
232 l.Error("failed to get issue")
233 rp.pages.Notice(w, noticeId, "Failed to delete issue.")
···242 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
243 return
244 }
245+ _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
246 Collection: tangled.RepoIssueNSID,
247 Repo: issue.Did,
248 Rkey: issue.Rkey,
···274 return
275 }
276277+ issue, ok := r.Context().Value("issue").(*models.Issue)
278 if !ok {
279 l.Error("failed to get issue")
280 rp.pages.Error404(w)
···302 return
303 }
304305+ // notify about the issue closure
306+ rp.notifier.NewIssueClosed(r.Context(), issue)
307+308 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
309 return
310 } else {
···323 return
324 }
325326+ issue, ok := r.Context().Value("issue").(*models.Issue)
327 if !ok {
328 l.Error("failed to get issue")
329 rp.pages.Error404(w)
···367 return
368 }
369370+ issue, ok := r.Context().Value("issue").(*models.Issue)
371 if !ok {
372 l.Error("failed to get issue")
373 rp.pages.Error404(w)
···386 replyTo = &replyToUri
387 }
388389+ comment := models.IssueComment{
390 Did: user.Did,
391 Rkey: tid.TID(),
392 IssueAt: issue.AtUri().String(),
···409 }
410411 // create a record first
412+ resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
413 Collection: tangled.RepoIssueCommentNSID,
414 Repo: comment.Did,
415 Rkey: comment.Rkey,
···438439 // reset atUri to make rollback a no-op
440 atUri = ""
441+442+ // notify about the new comment
443+ comment.Id = commentId
444+ rp.notifier.NewIssueComment(r.Context(), &comment)
445+446 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
447}
448···455 return
456 }
457458+ issue, ok := r.Context().Value("issue").(*models.Issue)
459 if !ok {
460 l.Error("failed to get issue")
461 rp.pages.Error404(w)
···496 return
497 }
498499+ issue, ok := r.Context().Value("issue").(*models.Issue)
500 if !ok {
501 l.Error("failed to get issue")
502 rp.pages.Error404(w)
···560 // rkey is optional, it was introduced later
561 if newComment.Rkey != "" {
562 // update the record on pds
563+ ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey)
564 if err != nil {
565 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey)
566 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
567 return
568 }
569570+ _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
571 Collection: tangled.RepoIssueCommentNSID,
572 Repo: user.Did,
573 Rkey: newComment.Rkey,
···600 return
601 }
602603+ issue, ok := r.Context().Value("issue").(*models.Issue)
604 if !ok {
605 l.Error("failed to get issue")
606 rp.pages.Error404(w)
···641 return
642 }
643644+ issue, ok := r.Context().Value("issue").(*models.Issue)
645 if !ok {
646 l.Error("failed to get issue")
647 rp.pages.Error404(w)
···682 return
683 }
684685+ issue, ok := r.Context().Value("issue").(*models.Issue)
686 if !ok {
687 l.Error("failed to get issue")
688 rp.pages.Error404(w)
···734 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
735 return
736 }
737+ _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
738 Collection: tangled.RepoIssueCommentNSID,
739 Repo: user.Did,
740 Rkey: comment.Rkey,
···760func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
761 params := r.URL.Query()
762 state := params.Get("state")
763+ searchQuery := params.Get("q")
764+ sortBy := params.Get("sort_by")
765+ sortOrder := params.Get("sort_order")
766+767+ // Use for template (preserve empty values)
768+ templateSortBy := sortBy
769+ templateSortOrder := sortOrder
770+771+ // Default sort values for queries
772+ if sortBy == "" {
773+ sortBy = "created"
774+ }
775+ if sortOrder == "" {
776+ sortOrder = "desc"
777+ }
778+779 isOpen := true
780 switch state {
781 case "open":
···803 if isOpen {
804 openVal = 1
805 }
806+807+ var issues []models.Issue
808+809+ // Parse the search query (even if empty, to handle label filters)
810+ query := search.Parse(searchQuery)
811+812+ // Always use search function to handle sorting
813+ issues, err = db.SearchIssues(
814 rp.db,
815 page,
816+ query.Text,
817+ query.Labels,
818+ sortBy,
819+ sortOrder,
820 db.FilterEq("repo_at", f.RepoAt()),
821 db.FilterEq("open", openVal),
822 )
823+824 if err != nil {
825 log.Println("failed to get issues", err)
826 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
827 return
828 }
829830+ labelDefs, err := db.GetLabelDefinitions(
831+ rp.db,
832+ db.FilterIn("at_uri", f.Repo.Labels),
833+ db.FilterContains("scope", tangled.RepoIssueNSID),
834+ )
835+ if err != nil {
836+ log.Println("failed to fetch labels", err)
837+ rp.pages.Error503(w)
838+ return
839+ }
840+841+ defs := make(map[string]*models.LabelDefinition)
842+ for _, l := range labelDefs {
843+ defs[l.AtUri().String()] = &l
844+ }
845+846 rp.pages.RepoIssues(w, pages.RepoIssuesParams{
847 LoggedInUser: rp.oauth.GetUser(r),
848 RepoInfo: f.RepoInfo(user),
849 Issues: issues,
850+ LabelDefs: defs,
851 FilteringByOpen: isOpen,
852 Page: page,
853+ SearchQuery: searchQuery,
854+ SortBy: templateSortBy,
855+ SortOrder: templateSortOrder,
856 })
857}
858···873 RepoInfo: f.RepoInfo(user),
874 })
875 case http.MethodPost:
876+ issue := &models.Issue{
877 RepoAt: f.RepoAt(),
878 Rkey: tid.TID(),
879 Title: r.FormValue("title"),
···897 rp.pages.Notice(w, "issues", "Failed to create issue.")
898 return
899 }
900+ resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
901 Collection: tangled.RepoIssueNSID,
902 Repo: user.Did,
903 Rkey: issue.Rkey,
···955// this is used to rollback changes made to the PDS
956//
957// it is a no-op if the provided ATURI is empty
958+func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
959 if aturi == "" {
960 return nil
961 }
···966 repo := parsed.Authority().String()
967 rkey := parsed.RecordKey().String()
968969+ _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
970 Collection: collection,
971 Repo: repo,
972 Rkey: rkey,
···1+package models
2+3+import (
4+ "time"
5+6+ "github.com/bluesky-social/indigo/atproto/syntax"
7+)
8+9+type Spindle struct {
10+ Id int
11+ Owner syntax.DID
12+ Instance string
13+ Verified *time.Time
14+ Created time.Time
15+ NeedsUpgrade bool
16+}
17+18+type SpindleMember struct {
19+ Id int
20+ Did syntax.DID // owner of the record
21+ Rkey string // rkey of the record
22+ Instance string
23+ Subject syntax.DID // the member being added
24+ Created time.Time
25+}
+17
appview/models/star.go
···00000000000000000
···1+package models
2+3+import (
4+ "time"
5+6+ "github.com/bluesky-social/indigo/atproto/syntax"
7+)
8+9+type Star struct {
10+ StarredByDid string
11+ RepoAt syntax.ATURI
12+ Created time.Time
13+ Rkey string
14+15+ // optionally, populate this when querying for reverse mappings
16+ Repo *Repo
17+}
···1+**Last updated:** September 26, 2025
2+3+This Privacy Policy describes how Tangled ("we," "us," or "our")
4+collects, uses, and shares your personal information when you use our
5+platform and services (the "Service").
6+7+## 1. Information We Collect
8+9+### Account Information
10+11+When you create an account, we collect:
12+13+- Your chosen username
14+- Email address
15+- Profile information you choose to provide
16+- Authentication data
17+18+### Content and Activity
19+20+We store:
21+22+- Code repositories and associated metadata
23+- Issues, pull requests, and comments
24+- Activity logs and usage patterns
25+- Public keys for authentication
26+27+## 2. Data Location and Hosting
28+29+### EU Data Hosting
30+31+**All Tangled service data is hosted within the European Union.**
32+Specifically:
33+34+- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
35+ (*.tngl.sh) are located in Finland
36+- **Application Data:** All other service data is stored on EU-based
37+ servers
38+- **Data Processing:** All data processing occurs within EU
39+ jurisdiction
40+41+### External PDS Notice
42+43+**Important:** If your account is hosted on Bluesky's PDS or other
44+self-hosted Personal Data Servers (not *.tngl.sh), we do not control
45+that data. The data protection, storage location, and privacy
46+practices for such accounts are governed by the respective PDS
47+provider's policies, not this Privacy Policy. We only control data
48+processing within our own services and infrastructure.
49+50+## 3. Third-Party Data Processors
51+52+We only share your data with the following third-party processors:
53+54+### Resend (Email Services)
55+56+- **Purpose:** Sending transactional emails (account verification,
57+ notifications)
58+- **Data Shared:** Email address and necessary message content
59+60+### Cloudflare (Image Caching)
61+62+- **Purpose:** Caching and optimizing image delivery
63+- **Data Shared:** Public images and associated metadata for caching
64+ purposes
65+66+### Posthog (Usage Metrics Tracking)
67+68+- **Purpose:** Tracking usage and platform metrics
69+- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
70+ information
71+72+## 4. How We Use Your Information
73+74+We use your information to:
75+76+- Provide and maintain the Service
77+- Process your transactions and requests
78+- Send you technical notices and support messages
79+- Improve and develop new features
80+- Ensure security and prevent fraud
81+- Comply with legal obligations
82+83+## 5. Data Sharing and Disclosure
84+85+We do not sell, trade, or rent your personal information. We may share
86+your information only in the following circumstances:
87+88+- With the third-party processors listed above
89+- When required by law or legal process
90+- To protect our rights, property, or safety, or that of our users
91+- In connection with a merger, acquisition, or sale of assets (with
92+ appropriate protections)
93+94+## 6. Data Security
95+96+We implement appropriate technical and organizational measures to
97+protect your personal information against unauthorized access,
98+alteration, disclosure, or destruction. However, no method of
99+transmission over the Internet is 100% secure.
100+101+## 7. Data Retention
102+103+We retain your personal information for as long as necessary to provide
104+the Service and fulfill the purposes outlined in this Privacy Policy,
105+unless a longer retention period is required by law.
106+107+## 8. Your Rights
108+109+Under applicable data protection laws, you have the right to:
110+111+- Access your personal information
112+- Correct inaccurate information
113+- Request deletion of your information
114+- Object to processing of your information
115+- Data portability
116+- Withdraw consent (where applicable)
117+118+## 9. Cookies and Tracking
119+120+We use cookies and similar technologies to:
121+122+- Maintain your login session
123+- Remember your preferences
124+- Analyze usage patterns to improve the Service
125+126+You can control cookie settings through your browser preferences.
127+128+## 10. Children's Privacy
129+130+The Service is not intended for children under 16 years of age. We do
131+not knowingly collect personal information from children under 16. If
132+we become aware that we have collected such information, we will take
133+steps to delete it.
134+135+## 11. International Data Transfers
136+137+While all our primary data processing occurs within the EU, some of our
138+third-party processors may process data outside the EU. When this
139+occurs, we ensure appropriate safeguards are in place, such as Standard
140+Contractual Clauses or adequacy decisions.
141+142+## 12. Changes to This Privacy Policy
143+144+We may update this Privacy Policy from time to time. We will notify you
145+of any changes by posting the new Privacy Policy on this page and
146+updating the "Last updated" date.
147+148+## 13. Contact Information
149+150+If you have any questions about this Privacy Policy or wish to exercise
151+your rights, please contact us through our platform or via email.
152+153+---
154+155+This Privacy Policy complies with the EU General Data Protection
156+Regulation (GDPR) and other applicable data protection laws.
···1+**Last updated:** September 26, 2025
2+3+Welcome to Tangled. These Terms of Service ("Terms") govern your access
4+to and use of the Tangled platform and services (the "Service")
5+operated by us ("Tangled," "we," "us," or "our").
6+7+## 1. Acceptance of Terms
8+9+By accessing or using our Service, you agree to be bound by these Terms.
10+If you disagree with any part of these terms, then you may not access
11+the Service.
12+13+## 2. Account Registration
14+15+To use certain features of the Service, you must register for an
16+account. You agree to provide accurate, current, and complete
17+information during the registration process and to update such
18+information to keep it accurate, current, and complete.
19+20+## 3. Account Termination
21+22+> **Important Notice**
23+>
24+> **We reserve the right to terminate, suspend, or restrict access to
25+> your account at any time, for any reason, or for no reason at all, at
26+> our sole discretion.** This includes, but is not limited to,
27+> termination for violation of these Terms, inappropriate conduct, spam,
28+> abuse, or any other behavior we deem harmful to the Service or other
29+> users.
30+>
31+> Account termination may result in the loss of access to your
32+> repositories, data, and other content associated with your account. We
33+> are not obligated to provide advance notice of termination, though we
34+> may do so in our discretion.
35+36+## 4. Acceptable Use
37+38+You agree not to use the Service to:
39+40+- Violate any applicable laws or regulations
41+- Infringe upon the rights of others
42+- Upload, store, or share content that is illegal, harmful, threatening,
43+ abusive, harassing, defamatory, vulgar, obscene, or otherwise
44+ objectionable
45+- Engage in spam, phishing, or other deceptive practices
46+- Attempt to gain unauthorized access to the Service or other users'
47+ accounts
48+- Interfere with or disrupt the Service or servers connected to the
49+ Service
50+51+## 5. Content and Intellectual Property
52+53+You retain ownership of the content you upload to the Service. By
54+uploading content, you grant us a non-exclusive, worldwide, royalty-free
55+license to use, reproduce, modify, and distribute your content as
56+necessary to provide the Service.
57+58+## 6. Privacy
59+60+Your privacy is important to us. Please review our [Privacy
61+Policy](/privacy), which also governs your use of the Service.
62+63+## 7. Disclaimers
64+65+The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
66+no warranties, expressed or implied, and hereby disclaim and negate all
67+other warranties including without limitation, implied warranties or
68+conditions of merchantability, fitness for a particular purpose, or
69+non-infringement of intellectual property or other violation of rights.
70+71+## 8. Limitation of Liability
72+73+In no event shall Tangled, nor its directors, employees, partners,
74+agents, suppliers, or affiliates, be liable for any indirect,
75+incidental, special, consequential, or punitive damages, including
76+without limitation, loss of profits, data, use, goodwill, or other
77+intangible losses, resulting from your use of the Service.
78+79+## 9. Indemnification
80+81+You agree to defend, indemnify, and hold harmless Tangled and its
82+affiliates, officers, directors, employees, and agents from and against
83+any and all claims, damages, obligations, losses, liabilities, costs,
84+or debt, and expenses (including attorney's fees).
85+86+## 10. Governing Law
87+88+These Terms shall be interpreted and governed by the laws of Finland,
89+without regard to its conflict of law provisions.
90+91+## 11. Changes to Terms
92+93+We reserve the right to modify or replace these Terms at any time. If a
94+revision is material, we will try to provide at least 30 days notice
95+prior to any new terms taking effect.
96+97+## 12. Contact Information
98+99+If you have any questions about these Terms of Service, please contact
100+us through our platform or via email.
101+102+---
103+104+These terms are effective as of the last updated date shown above and
105+will remain in effect except with respect to any changes in their
106+provisions in the future, which will be in effect immediately after
107+being posted on this page.
+15-17
appview/pages/markup/format.go
···1package markup
23-import "strings"
0045type Format string
6···10)
1112var FileTypes map[Format][]string = map[Format][]string{
13- FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14}
1516-// ReadmeFilenames contains the list of common README filenames to search for,
17-// in order of preference. Only includes well-supported formats.
18-var ReadmeFilenames = []string{
19- "README.md", "readme.md",
20- "README",
21- "readme",
22- "README.markdown",
23- "readme.markdown",
24- "README.txt",
25- "readme.txt",
26}
2728func GetFormat(filename string) Format {
29- for format, extensions := range FileTypes {
30- for _, extension := range extensions {
31- if strings.HasSuffix(filename, extension) {
32- return format
33- }
34 }
35 }
36 // default format
···1package markup
23+import (
4+ "regexp"
5+)
67type Format string
8···12)
1314var FileTypes map[Format][]string = map[Format][]string{
15+ FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
16}
1718+var FileTypePatterns = map[Format]*regexp.Regexp{
19+ FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
20+}
21+22+var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
23+24+func IsReadmeFile(filename string) bool {
25+ return ReadmePattern.MatchString(filename)
0026}
2728func GetFormat(filename string) Format {
29+ for format, pattern := range FileTypePatterns {
30+ if pattern.MatchString(filename) {
31+ return format
0032 }
33 }
34 // default format
···21 - `manual`: The workflow can be triggered manually.
22- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
2324-For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
2526```yaml
27when:
···21 - `manual`: The workflow can be triggered manually.
22- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
2324+For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
2526```yaml
27when:
···4 "net/http"
5 "path/filepath"
6 "time"
7+ "unicode/utf8"
89 "tangled.org/core/api/tangled"
10+ "tangled.org/core/appview/pages/markup"
11 "tangled.org/core/knotserver/git"
12 xrpcerr "tangled.org/core/xrpc/errors"
13)
···45 return
46 }
4748+ // if any of these files are a readme candidate, pass along its blob contents too
49+ var readmeFileName string
50+ var readmeContents string
51+ for _, file := range files {
52+ if markup.IsReadmeFile(file.Name) {
53+ contents, err := gr.RawContent(filepath.Join(path, file.Name))
54+ if err != nil {
55+ x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
56+ }
57+58+ if utf8.Valid(contents) {
59+ readmeFileName = file.Name
60+ readmeContents = string(contents)
61+ break
62+ }
63+ }
64+ }
65+66 // convert NiceTree -> tangled.RepoTree_TreeEntry
67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68 for i, file := range files {
···103 Parent: parentPtr,
104 Dotdot: dotdotPtr,
105 Files: treeEntries,
106+ Readme: &tangled.RepoTree_Readme{
107+ Filename: readmeFileName,
108+ Contents: readmeContents,
109+ },
110 }
111112 writeJson(w, response)
-158
legal/privacy.md
···1-# Privacy Policy
2-3-**Last updated:** January 15, 2025
4-5-This Privacy Policy describes how Tangled ("we," "us," or "our")
6-collects, uses, and shares your personal information when you use our
7-platform and services (the "Service").
8-9-## 1. Information We Collect
10-11-### Account Information
12-13-When you create an account, we collect:
14-15-- Your chosen username
16-- Email address
17-- Profile information you choose to provide
18-- Authentication data
19-20-### Content and Activity
21-22-We store:
23-24-- Code repositories and associated metadata
25-- Issues, pull requests, and comments
26-- Activity logs and usage patterns
27-- Public keys for authentication
28-29-## 2. Data Location and Hosting
30-31-### EU Data Hosting
32-33-**All Tangled service data is hosted within the European Union.**
34-Specifically:
35-36-- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
37- (*.tngl.sh) are located in Finland
38-- **Application Data:** All other service data is stored on EU-based
39- servers
40-- **Data Processing:** All data processing occurs within EU
41- jurisdiction
42-43-### External PDS Notice
44-45-**Important:** If your account is hosted on Bluesky's PDS or other
46-self-hosted Personal Data Servers (not *.tngl.sh), we do not control
47-that data. The data protection, storage location, and privacy
48-practices for such accounts are governed by the respective PDS
49-provider's policies, not this Privacy Policy. We only control data
50-processing within our own services and infrastructure.
51-52-## 3. Third-Party Data Processors
53-54-We only share your data with the following third-party processors:
55-56-### Resend (Email Services)
57-58-- **Purpose:** Sending transactional emails (account verification,
59- notifications)
60-- **Data Shared:** Email address and necessary message content
61-62-### Cloudflare (Image Caching)
63-64-- **Purpose:** Caching and optimizing image delivery
65-- **Data Shared:** Public images and associated metadata for caching
66- purposes
67-68-### Posthog (Usage Metrics Tracking)
69-70-- **Purpose:** Tracking usage and platform metrics
71-- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
72- information
73-74-## 4. How We Use Your Information
75-76-We use your information to:
77-78-- Provide and maintain the Service
79-- Process your transactions and requests
80-- Send you technical notices and support messages
81-- Improve and develop new features
82-- Ensure security and prevent fraud
83-- Comply with legal obligations
84-85-## 5. Data Sharing and Disclosure
86-87-We do not sell, trade, or rent your personal information. We may share
88-your information only in the following circumstances:
89-90-- With the third-party processors listed above
91-- When required by law or legal process
92-- To protect our rights, property, or safety, or that of our users
93-- In connection with a merger, acquisition, or sale of assets (with
94- appropriate protections)
95-96-## 6. Data Security
97-98-We implement appropriate technical and organizational measures to
99-protect your personal information against unauthorized access,
100-alteration, disclosure, or destruction. However, no method of
101-transmission over the Internet is 100% secure.
102-103-## 7. Data Retention
104-105-We retain your personal information for as long as necessary to provide
106-the Service and fulfill the purposes outlined in this Privacy Policy,
107-unless a longer retention period is required by law.
108-109-## 8. Your Rights
110-111-Under applicable data protection laws, you have the right to:
112-113-- Access your personal information
114-- Correct inaccurate information
115-- Request deletion of your information
116-- Object to processing of your information
117-- Data portability
118-- Withdraw consent (where applicable)
119-120-## 9. Cookies and Tracking
121-122-We use cookies and similar technologies to:
123-124-- Maintain your login session
125-- Remember your preferences
126-- Analyze usage patterns to improve the Service
127-128-You can control cookie settings through your browser preferences.
129-130-## 10. Children's Privacy
131-132-The Service is not intended for children under 16 years of age. We do
133-not knowingly collect personal information from children under 16. If
134-we become aware that we have collected such information, we will take
135-steps to delete it.
136-137-## 11. International Data Transfers
138-139-While all our primary data processing occurs within the EU, some of our
140-third-party processors may process data outside the EU. When this
141-occurs, we ensure appropriate safeguards are in place, such as Standard
142-Contractual Clauses or adequacy decisions.
143-144-## 12. Changes to This Privacy Policy
145-146-We may update this Privacy Policy from time to time. We will notify you
147-of any changes by posting the new Privacy Policy on this page and
148-updating the "Last updated" date.
149-150-## 13. Contact Information
151-152-If you have any questions about this Privacy Policy or wish to exercise
153-your rights, please contact us through our platform or via email.
154-155----
156-157-This Privacy Policy complies with the EU General Data Protection
158-Regulation (GDPR) and other applicable data protection laws.
···1-# Terms of Service
2-3-**Last updated:** January 15, 2025
4-5-Welcome to Tangled. These Terms of Service ("Terms") govern your access
6-to and use of the Tangled platform and services (the "Service")
7-operated by us ("Tangled," "we," "us," or "our").
8-9-## 1. Acceptance of Terms
10-11-By accessing or using our Service, you agree to be bound by these Terms.
12-If you disagree with any part of these terms, then you may not access
13-the Service.
14-15-## 2. Account Registration
16-17-To use certain features of the Service, you must register for an
18-account. You agree to provide accurate, current, and complete
19-information during the registration process and to update such
20-information to keep it accurate, current, and complete.
21-22-## 3. Account Termination
23-24-> **Important Notice**
25->
26-> **We reserve the right to terminate, suspend, or restrict access to
27-> your account at any time, for any reason, or for no reason at all, at
28-> our sole discretion.** This includes, but is not limited to,
29-> termination for violation of these Terms, inappropriate conduct, spam,
30-> abuse, or any other behavior we deem harmful to the Service or other
31-> users.
32->
33-> Account termination may result in the loss of access to your
34-> repositories, data, and other content associated with your account. We
35-> are not obligated to provide advance notice of termination, though we
36-> may do so in our discretion.
37-38-## 4. Acceptable Use
39-40-You agree not to use the Service to:
41-42-- Violate any applicable laws or regulations
43-- Infringe upon the rights of others
44-- Upload, store, or share content that is illegal, harmful, threatening,
45- abusive, harassing, defamatory, vulgar, obscene, or otherwise
46- objectionable
47-- Engage in spam, phishing, or other deceptive practices
48-- Attempt to gain unauthorized access to the Service or other users'
49- accounts
50-- Interfere with or disrupt the Service or servers connected to the
51- Service
52-53-## 5. Content and Intellectual Property
54-55-You retain ownership of the content you upload to the Service. By
56-uploading content, you grant us a non-exclusive, worldwide, royalty-free
57-license to use, reproduce, modify, and distribute your content as
58-necessary to provide the Service.
59-60-## 6. Privacy
61-62-Your privacy is important to us. Please review our [Privacy
63-Policy](/privacy), which also governs your use of the Service.
64-65-## 7. Disclaimers
66-67-The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
68-no warranties, expressed or implied, and hereby disclaim and negate all
69-other warranties including without limitation, implied warranties or
70-conditions of merchantability, fitness for a particular purpose, or
71-non-infringement of intellectual property or other violation of rights.
72-73-## 8. Limitation of Liability
74-75-In no event shall Tangled, nor its directors, employees, partners,
76-agents, suppliers, or affiliates, be liable for any indirect,
77-incidental, special, consequential, or punitive damages, including
78-without limitation, loss of profits, data, use, goodwill, or other
79-intangible losses, resulting from your use of the Service.
80-81-## 9. Indemnification
82-83-You agree to defend, indemnify, and hold harmless Tangled and its
84-affiliates, officers, directors, employees, and agents from and against
85-any and all claims, damages, obligations, losses, liabilities, costs,
86-or debt, and expenses (including attorney's fees).
87-88-## 10. Governing Law
89-90-These Terms shall be interpreted and governed by the laws of Finland,
91-without regard to its conflict of law provisions.
92-93-## 11. Changes to Terms
94-95-We reserve the right to modify or replace these Terms at any time. If a
96-revision is material, we will try to provide at least 30 days notice
97-prior to any new terms taking effect.
98-99-## 12. Contact Information
100-101-If you have any questions about these Terms of Service, please contact
102-us through our platform or via email.
103-104----
105-106-These terms are effective as of the last updated date shown above and
107-will remain in effect except with respect to any changes in their
108-provisions in the future, which will be in effect immediately after
109-being posted on this page.