···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+package tangled
44+55+// schema: sh.tangled.repo.tags
66+77+import (
88+ "bytes"
99+ "context"
1010+1111+ "github.com/bluesky-social/indigo/lex/util"
1212+)
1313+1414+const (
1515+ RepoTagsNSID = "sh.tangled.repo.tags"
1616+)
1717+1818+// RepoTags calls the XRPC method "sh.tangled.repo.tags".
1919+//
2020+// cursor: Pagination cursor
2121+// limit: Maximum number of tags to return
2222+// repo: Repository identifier in format 'did:plc:.../repoName'
2323+func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) {
2424+ buf := new(bytes.Buffer)
2525+2626+ params := map[string]interface{}{}
2727+ if cursor != "" {
2828+ params["cursor"] = cursor
2929+ }
3030+ if limit != 0 {
3131+ params["limit"] = limit
3232+ }
3333+ params["repo"] = repo
3434+ if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil {
3535+ return nil, err
3636+ }
3737+3838+ return buf.Bytes(), nil
3939+}
+72
api/tangled/repotree.go
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+package tangled
44+55+// schema: sh.tangled.repo.tree
66+77+import (
88+ "context"
99+1010+ "github.com/bluesky-social/indigo/lex/util"
1111+)
1212+1313+const (
1414+ RepoTreeNSID = "sh.tangled.repo.tree"
1515+)
1616+1717+// RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema.
1818+type RepoTree_LastCommit struct {
1919+ // hash: Commit hash
2020+ Hash string `json:"hash" cborgen:"hash"`
2121+ // message: Commit message
2222+ Message string `json:"message" cborgen:"message"`
2323+ // when: Commit timestamp
2424+ When string `json:"when" cborgen:"when"`
2525+}
2626+2727+// RepoTree_Output is the output of a sh.tangled.repo.tree call.
2828+type RepoTree_Output struct {
2929+ // dotdot: Parent directory path
3030+ Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"`
3131+ Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
3232+ // parent: The parent path in the tree
3333+ Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
3434+ // ref: The git reference used
3535+ Ref string `json:"ref" cborgen:"ref"`
3636+}
3737+3838+// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
3939+type RepoTree_TreeEntry struct {
4040+ // is_file: Whether this entry is a file
4141+ Is_file bool `json:"is_file" cborgen:"is_file"`
4242+ // is_subtree: Whether this entry is a directory/subtree
4343+ Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"`
4444+ Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"`
4545+ // mode: File mode
4646+ Mode string `json:"mode" cborgen:"mode"`
4747+ // name: Relative file or directory name
4848+ Name string `json:"name" cborgen:"name"`
4949+ // size: File size in bytes
5050+ Size int64 `json:"size" cborgen:"size"`
5151+}
5252+5353+// RepoTree calls the XRPC method "sh.tangled.repo.tree".
5454+//
5555+// path: Path within the repository tree
5656+// ref: Git reference (branch, tag, or commit SHA)
5757+// repo: Repository identifier in format 'did:plc:.../repoName'
5858+func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) {
5959+ var out RepoTree_Output
6060+6161+ params := map[string]interface{}{}
6262+ if path != "" {
6363+ params["path"] = path
6464+ }
6565+ params["ref"] = ref
6666+ params["repo"] = repo
6767+ if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil {
6868+ return nil, err
6969+ }
7070+7171+ return &out, nil
7272+}
+30
api/tangled/tangledowner.go
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+package tangled
44+55+// schema: sh.tangled.owner
66+77+import (
88+ "context"
99+1010+ "github.com/bluesky-social/indigo/lex/util"
1111+)
1212+1313+const (
1414+ OwnerNSID = "sh.tangled.owner"
1515+)
1616+1717+// Owner_Output is the output of a sh.tangled.owner call.
1818+type Owner_Output struct {
1919+ Owner string `json:"owner" cborgen:"owner"`
2020+}
2121+2222+// Owner calls the XRPC method "sh.tangled.owner".
2323+func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) {
2424+ var out Owner_Output
2525+ if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil {
2626+ return nil, err
2727+ }
2828+2929+ return &out, nil
3030+}
+1-1
appview/config/config.go
···2121 AppPassword string `env:"APP_PASSWORD"`
22222323 // uhhhh this is because knot1 is under icy's did
2424- TmpAltAppPassword string `env:"ALT_APP_PASSWORD, required"`
2424+ TmpAltAppPassword string `env:"ALT_APP_PASSWORD"`
2525}
26262727type OAuthConfig struct {
+169
appview/db/db.go
···703703 return err
704704 })
705705706706+ // repurpose the read-only column to "needs-upgrade"
707707+ runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error {
708708+ _, err := tx.Exec(`
709709+ alter table registrations rename column read_only to needs_upgrade;
710710+ `)
711711+ return err
712712+ })
713713+714714+ // require all knots to upgrade after the release of total xrpc
715715+ runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error {
716716+ _, err := tx.Exec(`
717717+ update registrations set needs_upgrade = 1;
718718+ `)
719719+ return err
720720+ })
721721+722722+ // require all knots to upgrade after the release of total xrpc
723723+ runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error {
724724+ _, err := tx.Exec(`
725725+ alter table spindles add column needs_upgrade integer not null default 0;
726726+ `)
727727+ if err != nil {
728728+ return err
729729+ }
730730+731731+ _, err = tx.Exec(`
732732+ update spindles set needs_upgrade = 1;
733733+ `)
734734+ return err
735735+ })
736736+737737+ // remove issue_at from issues and replace with generated column
738738+ //
739739+ // this requires a full table recreation because stored columns
740740+ // cannot be added via alter
741741+ //
742742+ // couple other changes:
743743+ // - columns renamed to be more consistent
744744+ // - adds edited and deleted fields
745745+ //
746746+ // disable foreign-keys for the next migration
747747+ conn.ExecContext(ctx, "pragma foreign_keys = off;")
748748+ runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error {
749749+ _, err := tx.Exec(`
750750+ create table if not exists issues_new (
751751+ -- identifiers
752752+ id integer primary key autoincrement,
753753+ did text not null,
754754+ rkey text not null,
755755+ at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored,
756756+757757+ -- at identifiers
758758+ repo_at text not null,
759759+760760+ -- content
761761+ issue_id integer not null,
762762+ title text not null,
763763+ body text not null,
764764+ open integer not null default 1,
765765+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
766766+ edited text, -- timestamp
767767+ deleted text, -- timestamp
768768+769769+ unique(did, rkey),
770770+ unique(repo_at, issue_id),
771771+ unique(at_uri),
772772+ foreign key (repo_at) references repos(at_uri) on delete cascade
773773+ );
774774+ `)
775775+ if err != nil {
776776+ return err
777777+ }
778778+779779+ // transfer data
780780+ _, err = tx.Exec(`
781781+ insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created)
782782+ select
783783+ i.id,
784784+ i.owner_did,
785785+ i.rkey,
786786+ i.repo_at,
787787+ i.issue_id,
788788+ i.title,
789789+ i.body,
790790+ i.open,
791791+ i.created
792792+ from issues i;
793793+ `)
794794+ if err != nil {
795795+ return err
796796+ }
797797+798798+ // drop old table
799799+ _, err = tx.Exec(`drop table issues`)
800800+ if err != nil {
801801+ return err
802802+ }
803803+804804+ // rename new table
805805+ _, err = tx.Exec(`alter table issues_new rename to issues`)
806806+ return err
807807+ })
808808+ conn.ExecContext(ctx, "pragma foreign_keys = on;")
809809+810810+ // - renames the comments table to 'issue_comments'
811811+ // - rework issue comments to update constraints:
812812+ // * unique(did, rkey)
813813+ // * remove comment-id and just use the global ID
814814+ // * foreign key (repo_at, issue_id)
815815+ // - new columns
816816+ // * column "reply_to" which can be any other comment
817817+ // * column "at-uri" which is a generated column
818818+ runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error {
819819+ _, err := tx.Exec(`
820820+ create table if not exists issue_comments (
821821+ -- identifiers
822822+ id integer primary key autoincrement,
823823+ did text not null,
824824+ rkey text,
825825+ at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored,
826826+827827+ -- at identifiers
828828+ issue_at text not null,
829829+ reply_to text, -- at_uri of parent comment
830830+831831+ -- content
832832+ body text not null,
833833+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
834834+ edited text,
835835+ deleted text,
836836+837837+ -- constraints
838838+ unique(did, rkey),
839839+ unique(at_uri),
840840+ foreign key (issue_at) references issues(at_uri) on delete cascade
841841+ );
842842+ `)
843843+ if err != nil {
844844+ return err
845845+ }
846846+847847+ // transfer data
848848+ _, err = tx.Exec(`
849849+ insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted)
850850+ select
851851+ c.id,
852852+ c.owner_did,
853853+ c.rkey,
854854+ i.at_uri, -- get at_uri from issues table
855855+ c.body,
856856+ c.created,
857857+ c.edited,
858858+ c.deleted
859859+ from comments c
860860+ join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id;
861861+ `)
862862+ if err != nil {
863863+ return err
864864+ }
865865+866866+ // drop old table
867867+ _, err = tx.Exec(`drop table comments`)
868868+ return err
869869+ })
870870+706871 return &DB{db}, nil
707872}
708873···747912 }
748913749914 return nil
915915+}
916916+917917+func (d *DB) Close() error {
918918+ return d.DB.Close()
750919}
751920752921type filter struct {
+4-4
appview/db/follow.go
···5656}
57575858type FollowStats struct {
5959- Followers int
6060- Following int
5959+ Followers int64
6060+ Following int64
6161}
62626363func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
6464- followers, following := 0, 0
6464+ var followers, following int64
6565 err := e.QueryRow(
6666 `SELECT
6767 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers,
···122122123123 for rows.Next() {
124124 var did string
125125- var followers, following int
125125+ var followers, following int64
126126 if err := rows.Scan(&did, &followers, &following); err != nil {
127127 return nil, err
128128 }
+430-368
appview/db/issues.go
···33import (
44 "database/sql"
55 "fmt"
66+ "maps"
77+ "slices"
88+ "sort"
69 "strings"
710 "time"
811···1215)
13161417type Issue struct {
1515- ID int64
1616- RepoAt syntax.ATURI
1717- OwnerDid string
1818- IssueId int
1919- Rkey string
2020- Created time.Time
2121- Title string
2222- Body string
2323- Open bool
1818+ Id int64
1919+ Did string
2020+ Rkey string
2121+ RepoAt syntax.ATURI
2222+ IssueId int
2323+ Created time.Time
2424+ Edited *time.Time
2525+ Deleted *time.Time
2626+ Title string
2727+ Body string
2828+ Open bool
24292530 // optionally, populate this when querying for reverse mappings
2631 // like comment counts, parent repo etc.
2727- Metadata *IssueMetadata
3232+ Comments []IssueComment
3333+ Repo *Repo
2834}
29353030-type IssueMetadata struct {
3131- CommentCount int
3232- Repo *Repo
3333- // labels, assignee etc.
3636+func (i *Issue) AtUri() syntax.ATURI {
3737+ return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
3438}
35393636-type Comment struct {
3737- OwnerDid string
3838- RepoAt syntax.ATURI
3939- Rkey string
4040- Issue int
4141- CommentId int
4242- Body string
4343- Created *time.Time
4444- Deleted *time.Time
4545- Edited *time.Time
4040+func (i *Issue) AsRecord() tangled.RepoIssue {
4141+ return tangled.RepoIssue{
4242+ Repo: i.RepoAt.String(),
4343+ Title: i.Title,
4444+ Body: &i.Body,
4545+ CreatedAt: i.Created.Format(time.RFC3339),
4646+ }
4647}
47484848-func (i *Issue) AtUri() syntax.ATURI {
4949- return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.OwnerDid, tangled.RepoIssueNSID, i.Rkey))
4949+func (i *Issue) State() string {
5050+ if i.Open {
5151+ return "open"
5252+ }
5353+ return "closed"
5454+}
5555+5656+type CommentListItem struct {
5757+ Self *IssueComment
5858+ Replies []*IssueComment
5059}
51605252-func NewIssue(tx *sql.Tx, issue *Issue) error {
5353- defer tx.Rollback()
6161+func (i *Issue) CommentList() []CommentListItem {
6262+ // Create a map to quickly find comments by their aturi
6363+ toplevel := make(map[string]*CommentListItem)
6464+ var replies []*IssueComment
54655555- _, err := tx.Exec(`
5656- insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
5757- values (?, 1)
5858- `, issue.RepoAt)
5959- if err != nil {
6060- return err
6666+ // collect top level comments into the map
6767+ for _, comment := range i.Comments {
6868+ if comment.IsTopLevel() {
6969+ toplevel[comment.AtUri().String()] = &CommentListItem{
7070+ Self: &comment,
7171+ }
7272+ } else {
7373+ replies = append(replies, &comment)
7474+ }
6175 }
62766363- var nextId int
6464- err = tx.QueryRow(`
6565- update repo_issue_seqs
6666- set next_issue_id = next_issue_id + 1
6767- where repo_at = ?
6868- returning next_issue_id - 1
6969- `, issue.RepoAt).Scan(&nextId)
7070- if err != nil {
7171- return err
7777+ for _, r := range replies {
7878+ parentAt := *r.ReplyTo
7979+ if parent, exists := toplevel[parentAt]; exists {
8080+ parent.Replies = append(parent.Replies, r)
8181+ }
7282 }
73837474- issue.IssueId = nextId
8484+ var listing []CommentListItem
8585+ for _, v := range toplevel {
8686+ listing = append(listing, *v)
8787+ }
75887676- res, err := tx.Exec(`
7777- insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
7878- values (?, ?, ?, ?, ?, ?, ?)
7979- `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
8080- if err != nil {
8181- return err
8989+ // sort everything
9090+ sortFunc := func(a, b *IssueComment) bool {
9191+ return a.Created.Before(b.Created)
9292+ }
9393+ sort.Slice(listing, func(i, j int) bool {
9494+ return sortFunc(listing[i].Self, listing[j].Self)
9595+ })
9696+ for _, r := range listing {
9797+ sort.Slice(r.Replies, func(i, j int) bool {
9898+ return sortFunc(r.Replies[i], r.Replies[j])
9999+ })
82100 }
831018484- lastID, err := res.LastInsertId()
102102+ return listing
103103+}
104104+105105+func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
106106+ created, err := time.Parse(time.RFC3339, record.CreatedAt)
85107 if err != nil {
8686- return err
108108+ created = time.Now()
87109 }
8888- issue.ID = lastID
891109090- if err := tx.Commit(); err != nil {
9191- return err
111111+ body := ""
112112+ if record.Body != nil {
113113+ body = *record.Body
92114 }
931159494- return nil
116116+ return Issue{
117117+ RepoAt: syntax.ATURI(record.Repo),
118118+ Did: did,
119119+ Rkey: rkey,
120120+ Created: created,
121121+ Title: record.Title,
122122+ Body: body,
123123+ Open: true, // new issues are open by default
124124+ }
95125}
961269797-func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
9898- var issueAt string
9999- err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
100100- return issueAt, err
127127+type IssueComment struct {
128128+ Id int64
129129+ Did string
130130+ Rkey string
131131+ IssueAt string
132132+ ReplyTo *string
133133+ Body string
134134+ Created time.Time
135135+ Edited *time.Time
136136+ Deleted *time.Time
101137}
102138103103-func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
104104- var ownerDid string
105105- err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
106106- return ownerDid, err
139139+func (i *IssueComment) AtUri() syntax.ATURI {
140140+ return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
107141}
108142109109-func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
110110- var issues []Issue
111111- openValue := 0
112112- if isOpen {
113113- openValue = 1
143143+func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
144144+ return tangled.RepoIssueComment{
145145+ Body: i.Body,
146146+ Issue: i.IssueAt,
147147+ CreatedAt: i.Created.Format(time.RFC3339),
148148+ ReplyTo: i.ReplyTo,
114149 }
150150+}
115151116116- rows, err := e.Query(
117117- `
118118- with numbered_issue as (
119119- select
120120- i.id,
121121- i.owner_did,
122122- i.rkey,
123123- i.issue_id,
124124- i.created,
125125- i.title,
126126- i.body,
127127- i.open,
128128- count(c.id) as comment_count,
129129- row_number() over (order by i.created desc) as row_num
130130- from
131131- issues i
132132- left join
133133- comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
134134- where
135135- i.repo_at = ? and i.open = ?
136136- group by
137137- i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
138138- )
139139- select
140140- id,
141141- owner_did,
142142- rkey,
143143- issue_id,
144144- created,
145145- title,
146146- body,
147147- open,
148148- comment_count
149149- from
150150- numbered_issue
151151- where
152152- row_num between ? and ?`,
153153- repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
152152+func (i *IssueComment) IsTopLevel() bool {
153153+ return i.ReplyTo == nil
154154+}
155155+156156+func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
157157+ created, err := time.Parse(time.RFC3339, record.CreatedAt)
154158 if err != nil {
159159+ created = time.Now()
160160+ }
161161+162162+ ownerDid := did
163163+164164+ if _, err = syntax.ParseATURI(record.Issue); err != nil {
155165 return nil, err
156166 }
157157- defer rows.Close()
158167159159- for rows.Next() {
160160- var issue Issue
161161- var createdAt string
162162- var metadata IssueMetadata
163163- err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
164164- if err != nil {
165165- return nil, err
166166- }
168168+ comment := IssueComment{
169169+ Did: ownerDid,
170170+ Rkey: rkey,
171171+ Body: record.Body,
172172+ IssueAt: record.Issue,
173173+ ReplyTo: record.ReplyTo,
174174+ Created: created,
175175+ }
176176+177177+ return &comment, nil
178178+}
167179168168- createdTime, err := time.Parse(time.RFC3339, createdAt)
169169- if err != nil {
170170- return nil, err
180180+func PutIssue(tx *sql.Tx, issue *Issue) error {
181181+ // ensure sequence exists
182182+ _, err := tx.Exec(`
183183+ insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
184184+ values (?, 1)
185185+ `, issue.RepoAt)
186186+ if err != nil {
187187+ return err
188188+ }
189189+190190+ issues, err := GetIssues(
191191+ tx,
192192+ FilterEq("did", issue.Did),
193193+ FilterEq("rkey", issue.Rkey),
194194+ )
195195+ switch {
196196+ case err != nil:
197197+ return err
198198+ case len(issues) == 0:
199199+ return createNewIssue(tx, issue)
200200+ case len(issues) != 1: // should be unreachable
201201+ return fmt.Errorf("invalid number of issues returned: %d", len(issues))
202202+ default:
203203+ // if content is identical, do not edit
204204+ existingIssue := issues[0]
205205+ if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body {
206206+ return nil
171207 }
172172- issue.Created = createdTime
173173- issue.Metadata = &metadata
174208175175- issues = append(issues, issue)
209209+ issue.Id = existingIssue.Id
210210+ issue.IssueId = existingIssue.IssueId
211211+ return updateIssue(tx, issue)
176212 }
213213+}
177214178178- if err := rows.Err(); err != nil {
179179- return nil, err
215215+func createNewIssue(tx *sql.Tx, issue *Issue) error {
216216+ // get next issue_id
217217+ var newIssueId int
218218+ err := tx.QueryRow(`
219219+ update repo_issue_seqs
220220+ set next_issue_id = next_issue_id + 1
221221+ where repo_at = ?
222222+ returning next_issue_id - 1
223223+ `, issue.RepoAt).Scan(&newIssueId)
224224+ if err != nil {
225225+ return err
180226 }
181227182182- return issues, nil
228228+ // insert new issue
229229+ row := tx.QueryRow(`
230230+ insert into issues (repo_at, did, rkey, issue_id, title, body)
231231+ values (?, ?, ?, ?, ?, ?)
232232+ returning rowid, issue_id
233233+ `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body)
234234+235235+ return row.Scan(&issue.Id, &issue.IssueId)
236236+}
237237+238238+func updateIssue(tx *sql.Tx, issue *Issue) error {
239239+ // update existing issue
240240+ _, err := tx.Exec(`
241241+ update issues
242242+ set title = ?, body = ?, edited = ?
243243+ where did = ? and rkey = ?
244244+ `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey)
245245+ return err
183246}
184247185185-func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
186186- issues := make([]Issue, 0, limit)
248248+func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
249249+ issueMap := make(map[string]*Issue) // at-uri -> issue
187250188251 var conditions []string
189252 var args []any
253253+190254 for _, filter := range filters {
191255 conditions = append(conditions, filter.Condition())
192256 args = append(args, filter.Arg()...)
···196260 if conditions != nil {
197261 whereClause = " where " + strings.Join(conditions, " and ")
198262 }
199199- limitClause := ""
200200- if limit != 0 {
201201- limitClause = fmt.Sprintf(" limit %d ", limit)
202202- }
263263+264264+ pLower := FilterGte("row_num", page.Offset+1)
265265+ pUpper := FilterLte("row_num", page.Offset+page.Limit)
266266+267267+ args = append(args, pLower.Arg()...)
268268+ args = append(args, pUpper.Arg()...)
269269+ pagination := " where " + pLower.Condition() + " and " + pUpper.Condition()
203270204271 query := fmt.Sprintf(
205205- `select
206206- i.id,
207207- i.owner_did,
208208- i.repo_at,
209209- i.issue_id,
210210- i.created,
211211- i.title,
212212- i.body,
213213- i.open
214214- from
215215- issues i
272272+ `
273273+ select * from (
274274+ select
275275+ id,
276276+ did,
277277+ rkey,
278278+ repo_at,
279279+ issue_id,
280280+ title,
281281+ body,
282282+ open,
283283+ created,
284284+ edited,
285285+ deleted,
286286+ row_number() over (order by created desc) as row_num
287287+ from
288288+ issues
289289+ %s
290290+ ) ranked_issues
216291 %s
217217- order by
218218- i.created desc
219219- %s`,
220220- whereClause, limitClause)
292292+ `,
293293+ whereClause,
294294+ pagination,
295295+ )
221296222297 rows, err := e.Query(query, args...)
223298 if err != nil {
224224- return nil, err
299299+ return nil, fmt.Errorf("failed to query issues table: %w", err)
225300 }
226301 defer rows.Close()
227302228303 for rows.Next() {
229304 var issue Issue
230230- var issueCreatedAt string
305305+ var createdAt string
306306+ var editedAt, deletedAt sql.Null[string]
307307+ var rowNum int64
231308 err := rows.Scan(
232232- &issue.ID,
233233- &issue.OwnerDid,
309309+ &issue.Id,
310310+ &issue.Did,
311311+ &issue.Rkey,
234312 &issue.RepoAt,
235313 &issue.IssueId,
236236- &issueCreatedAt,
237314 &issue.Title,
238315 &issue.Body,
239316 &issue.Open,
317317+ &createdAt,
318318+ &editedAt,
319319+ &deletedAt,
320320+ &rowNum,
240321 )
241322 if err != nil {
242242- return nil, err
323323+ return nil, fmt.Errorf("failed to scan issue: %w", err)
243324 }
244325245245- issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
246246- if err != nil {
247247- return nil, err
326326+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
327327+ issue.Created = t
248328 }
249249- issue.Created = issueCreatedTime
250329251251- issues = append(issues, issue)
252252- }
253253-254254- if err := rows.Err(); err != nil {
255255- return nil, err
256256- }
330330+ if editedAt.Valid {
331331+ if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
332332+ issue.Edited = &t
333333+ }
334334+ }
257335258258- return issues, nil
259259-}
336336+ if deletedAt.Valid {
337337+ if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
338338+ issue.Deleted = &t
339339+ }
340340+ }
260341261261-func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
262262- return GetIssuesWithLimit(e, 0, filters...)
263263-}
342342+ atUri := issue.AtUri().String()
343343+ issueMap[atUri] = &issue
344344+ }
264345265265-// timeframe here is directly passed into the sql query filter, and any
266266-// timeframe in the past should be negative; e.g.: "-3 months"
267267-func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
268268- var issues []Issue
346346+ // collect reverse repos
347347+ repoAts := make([]string, 0, len(issueMap)) // or just []string{}
348348+ for _, issue := range issueMap {
349349+ repoAts = append(repoAts, string(issue.RepoAt))
350350+ }
269351270270- rows, err := e.Query(
271271- `select
272272- i.id,
273273- i.owner_did,
274274- i.rkey,
275275- i.repo_at,
276276- i.issue_id,
277277- i.created,
278278- i.title,
279279- i.body,
280280- i.open,
281281- r.did,
282282- r.name,
283283- r.knot,
284284- r.rkey,
285285- r.created
286286- from
287287- issues i
288288- join
289289- repos r on i.repo_at = r.at_uri
290290- where
291291- i.owner_did = ? and i.created >= date ('now', ?)
292292- order by
293293- i.created desc`,
294294- ownerDid, timeframe)
352352+ repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
295353 if err != nil {
296296- return nil, err
354354+ return nil, fmt.Errorf("failed to build repo mappings: %w", err)
297355 }
298298- defer rows.Close()
299356300300- for rows.Next() {
301301- var issue Issue
302302- var issueCreatedAt, repoCreatedAt string
303303- var repo Repo
304304- err := rows.Scan(
305305- &issue.ID,
306306- &issue.OwnerDid,
307307- &issue.Rkey,
308308- &issue.RepoAt,
309309- &issue.IssueId,
310310- &issueCreatedAt,
311311- &issue.Title,
312312- &issue.Body,
313313- &issue.Open,
314314- &repo.Did,
315315- &repo.Name,
316316- &repo.Knot,
317317- &repo.Rkey,
318318- &repoCreatedAt,
319319- )
320320- if err != nil {
321321- return nil, err
322322- }
357357+ repoMap := make(map[string]*Repo)
358358+ for i := range repos {
359359+ repoMap[string(repos[i].RepoAt())] = &repos[i]
360360+ }
323361324324- issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
325325- if err != nil {
326326- return nil, err
362362+ for issueAt, i := range issueMap {
363363+ if r, ok := repoMap[string(i.RepoAt)]; ok {
364364+ i.Repo = r
365365+ } else {
366366+ // do not show up the issue if the repo is deleted
367367+ // TODO: foreign key where?
368368+ delete(issueMap, issueAt)
327369 }
328328- issue.Created = issueCreatedTime
370370+ }
329371330330- repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
331331- if err != nil {
332332- return nil, err
333333- }
334334- repo.Created = repoCreatedTime
372372+ // collect comments
373373+ issueAts := slices.Collect(maps.Keys(issueMap))
374374+ comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts))
375375+ if err != nil {
376376+ return nil, fmt.Errorf("failed to query comments: %w", err)
377377+ }
335378336336- issue.Metadata = &IssueMetadata{
337337- Repo: &repo,
379379+ for i := range comments {
380380+ issueAt := comments[i].IssueAt
381381+ if issue, ok := issueMap[issueAt]; ok {
382382+ issue.Comments = append(issue.Comments, comments[i])
338383 }
384384+ }
339385340340- issues = append(issues, issue)
386386+ var issues []Issue
387387+ for _, i := range issueMap {
388388+ issues = append(issues, *i)
341389 }
342390343343- if err := rows.Err(); err != nil {
344344- return nil, err
345345- }
391391+ sort.Slice(issues, func(i, j int) bool {
392392+ return issues[i].Created.After(issues[j].Created)
393393+ })
346394347395 return issues, nil
396396+}
397397+398398+func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
399399+ return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
348400}
349401350402func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
···353405354406 var issue Issue
355407 var createdAt string
356356- err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
408408+ err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
357409 if err != nil {
358410 return nil, err
359411 }
···367419 return &issue, nil
368420}
369421370370-func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
371371- query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
372372- row := e.QueryRow(query, repoAt, issueId)
373373-374374- var issue Issue
375375- var createdAt string
376376- err := row.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
422422+func AddIssueComment(e Execer, c IssueComment) (int64, error) {
423423+ result, err := e.Exec(
424424+ `insert into issue_comments (
425425+ did,
426426+ rkey,
427427+ issue_at,
428428+ body,
429429+ reply_to,
430430+ created,
431431+ edited
432432+ )
433433+ values (?, ?, ?, ?, ?, ?, null)
434434+ on conflict(did, rkey) do update set
435435+ issue_at = excluded.issue_at,
436436+ body = excluded.body,
437437+ edited = case
438438+ when
439439+ issue_comments.issue_at != excluded.issue_at
440440+ or issue_comments.body != excluded.body
441441+ or issue_comments.reply_to != excluded.reply_to
442442+ then ?
443443+ else issue_comments.edited
444444+ end`,
445445+ c.Did,
446446+ c.Rkey,
447447+ c.IssueAt,
448448+ c.Body,
449449+ c.ReplyTo,
450450+ c.Created.Format(time.RFC3339),
451451+ time.Now().Format(time.RFC3339),
452452+ )
377453 if err != nil {
378378- return nil, nil, err
454454+ return 0, err
379455 }
380456381381- createdTime, err := time.Parse(time.RFC3339, createdAt)
457457+ id, err := result.LastInsertId()
382458 if err != nil {
383383- return nil, nil, err
459459+ return 0, err
384460 }
385385- issue.Created = createdTime
386461387387- comments, err := GetComments(e, repoAt, issueId)
388388- if err != nil {
389389- return nil, nil, err
462462+ return id, nil
463463+}
464464+465465+func DeleteIssueComments(e Execer, filters ...filter) error {
466466+ var conditions []string
467467+ var args []any
468468+ for _, filter := range filters {
469469+ conditions = append(conditions, filter.Condition())
470470+ args = append(args, filter.Arg()...)
390471 }
391472392392- return &issue, comments, nil
393393-}
473473+ whereClause := ""
474474+ if conditions != nil {
475475+ whereClause = " where " + strings.Join(conditions, " and ")
476476+ }
394477395395-func NewIssueComment(e Execer, comment *Comment) error {
396396- query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
397397- _, err := e.Exec(
398398- query,
399399- comment.OwnerDid,
400400- comment.RepoAt,
401401- comment.Rkey,
402402- comment.Issue,
403403- comment.CommentId,
404404- comment.Body,
405405- )
478478+ query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
479479+480480+ _, err := e.Exec(query, args...)
406481 return err
407482}
408483409409-func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
410410- var comments []Comment
484484+func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
485485+ var comments []IssueComment
486486+487487+ var conditions []string
488488+ var args []any
489489+ for _, filter := range filters {
490490+ conditions = append(conditions, filter.Condition())
491491+ args = append(args, filter.Arg()...)
492492+ }
411493412412- rows, err := e.Query(`
494494+ whereClause := ""
495495+ if conditions != nil {
496496+ whereClause = " where " + strings.Join(conditions, " and ")
497497+ }
498498+499499+ query := fmt.Sprintf(`
413500 select
414414- owner_did,
415415- issue_id,
416416- comment_id,
501501+ id,
502502+ did,
417503 rkey,
504504+ issue_at,
505505+ reply_to,
418506 body,
419507 created,
420508 edited,
421509 deleted
422510 from
423423- comments
424424- where
425425- repo_at = ? and issue_id = ?
426426- order by
427427- created asc`,
428428- repoAt,
429429- issueId,
430430- )
431431- if err == sql.ErrNoRows {
432432- return []Comment{}, nil
433433- }
511511+ issue_comments
512512+ %s
513513+ `, whereClause)
514514+515515+ rows, err := e.Query(query, args...)
434516 if err != nil {
435517 return nil, err
436518 }
437437- defer rows.Close()
438519439520 for rows.Next() {
440440- var comment Comment
441441- var createdAt string
442442- var deletedAt, editedAt, rkey sql.NullString
443443- err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
521521+ var comment IssueComment
522522+ var created string
523523+ var rkey, edited, deleted, replyTo sql.Null[string]
524524+ err := rows.Scan(
525525+ &comment.Id,
526526+ &comment.Did,
527527+ &rkey,
528528+ &comment.IssueAt,
529529+ &replyTo,
530530+ &comment.Body,
531531+ &created,
532532+ &edited,
533533+ &deleted,
534534+ )
444535 if err != nil {
445536 return nil, err
446537 }
447538448448- createdAtTime, err := time.Parse(time.RFC3339, createdAt)
449449- if err != nil {
450450- return nil, err
539539+ // this is a remnant from old times, newer comments always have rkey
540540+ if rkey.Valid {
541541+ comment.Rkey = rkey.V
451542 }
452452- comment.Created = &createdAtTime
453543454454- if deletedAt.Valid {
455455- deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
456456- if err != nil {
457457- return nil, err
544544+ if t, err := time.Parse(time.RFC3339, created); err == nil {
545545+ comment.Created = t
546546+ }
547547+548548+ if edited.Valid {
549549+ if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
550550+ comment.Edited = &t
458551 }
459459- comment.Deleted = &deletedTime
460552 }
461553462462- if editedAt.Valid {
463463- editedTime, err := time.Parse(time.RFC3339, editedAt.String)
464464- if err != nil {
465465- return nil, err
554554+ if deleted.Valid {
555555+ if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
556556+ comment.Deleted = &t
466557 }
467467- comment.Edited = &editedTime
468558 }
469559470470- if rkey.Valid {
471471- comment.Rkey = rkey.String
560560+ if replyTo.Valid {
561561+ comment.ReplyTo = &replyTo.V
472562 }
473563474564 comments = append(comments, comment)
475565 }
476566477477- if err := rows.Err(); err != nil {
567567+ if err = rows.Err(); err != nil {
478568 return nil, err
479569 }
480570481571 return comments, nil
482572}
483573484484-func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
485485- query := `
486486- select
487487- owner_did, body, rkey, created, deleted, edited
488488- from
489489- comments where repo_at = ? and issue_id = ? and comment_id = ?
490490- `
491491- row := e.QueryRow(query, repoAt, issueId, commentId)
492492-493493- var comment Comment
494494- var createdAt string
495495- var deletedAt, editedAt, rkey sql.NullString
496496- err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
497497- if err != nil {
498498- return nil, err
574574+func DeleteIssues(e Execer, filters ...filter) error {
575575+ var conditions []string
576576+ var args []any
577577+ for _, filter := range filters {
578578+ conditions = append(conditions, filter.Condition())
579579+ args = append(args, filter.Arg()...)
499580 }
500581501501- createdTime, err := time.Parse(time.RFC3339, createdAt)
502502- if err != nil {
503503- return nil, err
582582+ whereClause := ""
583583+ if conditions != nil {
584584+ whereClause = " where " + strings.Join(conditions, " and ")
504585 }
505505- comment.Created = &createdTime
506586507507- if deletedAt.Valid {
508508- deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
509509- if err != nil {
510510- return nil, err
511511- }
512512- comment.Deleted = &deletedTime
513513- }
587587+ query := fmt.Sprintf(`delete from issues %s`, whereClause)
588588+ _, err := e.Exec(query, args...)
589589+ return err
590590+}
514591515515- if editedAt.Valid {
516516- editedTime, err := time.Parse(time.RFC3339, editedAt.String)
517517- if err != nil {
518518- return nil, err
519519- }
520520- comment.Edited = &editedTime
592592+func CloseIssues(e Execer, filters ...filter) error {
593593+ var conditions []string
594594+ var args []any
595595+ for _, filter := range filters {
596596+ conditions = append(conditions, filter.Condition())
597597+ args = append(args, filter.Arg()...)
521598 }
522599523523- if rkey.Valid {
524524- comment.Rkey = rkey.String
600600+ whereClause := ""
601601+ if conditions != nil {
602602+ whereClause = " where " + strings.Join(conditions, " and ")
525603 }
526604527527- comment.RepoAt = repoAt
528528- comment.Issue = issueId
529529- comment.CommentId = commentId
530530-531531- return &comment, nil
532532-}
533533-534534-func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
535535- _, err := e.Exec(
536536- `
537537- update comments
538538- set body = ?,
539539- edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
540540- where repo_at = ? and issue_id = ? and comment_id = ?
541541- `, newBody, repoAt, issueId, commentId)
605605+ query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
606606+ _, err := e.Exec(query, args...)
542607 return err
543608}
544609545545-func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
546546- _, err := e.Exec(
547547- `
548548- update comments
549549- set body = "",
550550- deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
551551- where repo_at = ? and issue_id = ? and comment_id = ?
552552- `, repoAt, issueId, commentId)
553553- return err
554554-}
610610+func ReopenIssues(e Execer, filters ...filter) error {
611611+ var conditions []string
612612+ var args []any
613613+ for _, filter := range filters {
614614+ conditions = append(conditions, filter.Condition())
615615+ args = append(args, filter.Arg()...)
616616+ }
555617556556-func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
557557- _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
558558- return err
559559-}
618618+ whereClause := ""
619619+ if conditions != nil {
620620+ whereClause = " where " + strings.Join(conditions, " and ")
621621+ }
560622561561-func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
562562- _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId)
623623+ query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause)
624624+ _, err := e.Exec(query, args...)
563625 return err
564626}
565627
+23-5
appview/db/profile.go
···2222 ByMonth []ByMonth
2323}
24242525+func (p *ProfileTimeline) IsEmpty() bool {
2626+ if p == nil {
2727+ return true
2828+ }
2929+3030+ for _, m := range p.ByMonth {
3131+ if !m.IsEmpty() {
3232+ return false
3333+ }
3434+ }
3535+3636+ return true
3737+}
3838+2539type ByMonth struct {
2640 RepoEvents []RepoEvent
2741 IssueEvents IssueEvents
···118132 *items = append(*items, &pull)
119133 }
120134121121- issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
135135+ issues, err := GetIssues(
136136+ e,
137137+ FilterEq("did", forDid),
138138+ FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)),
139139+ )
122140 if err != nil {
123141 return nil, fmt.Errorf("error getting issues by owner did: %w", err)
124142 }
···137155 *items = append(*items, &issue)
138156 }
139157140140- repos, err := GetAllReposByDid(e, forDid)
158158+ repos, err := GetRepos(e, 0, FilterEq("did", forDid))
141159 if err != nil {
142160 return nil, fmt.Errorf("error getting all repos by did: %w", err)
143161 }
···535553 query = `select count(id) from pulls where owner_did = ? and state = ?`
536554 args = append(args, did, PullOpen)
537555 case VanityStatOpenIssueCount:
538538- query = `select count(id) from issues where owner_did = ? and open = 1`
556556+ query = `select count(id) from issues where did = ? and open = 1`
539557 args = append(args, did)
540558 case VanityStatClosedIssueCount:
541541- query = `select count(id) from issues where owner_did = ? and open = 0`
559559+ query = `select count(id) from issues where did = ? and open = 0`
542560 args = append(args, did)
543561 case VanityStatRepositoryCount:
544562 query = `select count(id) from repos where did = ?`
···572590 }
573591574592 // ensure all pinned repos are either own repos or collaborating repos
575575- repos, err := GetAllReposByDid(e, profile.Did)
593593+ repos, err := GetRepos(e, 0, FilterEq("did", profile.Did))
576594 if err != nil {
577595 log.Printf("getting repos for %s: %s", profile.Did, err)
578596 }
···1313 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
1414}
15151616+// ReadmeFilenames contains the list of common README filenames to search for,
1717+// in order of preference. Only includes well-supported formats.
1818+var ReadmeFilenames = []string{
1919+ "README.md", "readme.md",
2020+ "README",
2121+ "readme",
2222+ "README.markdown",
2323+ "readme.markdown",
2424+ "README.txt",
2525+ "readme.txt",
2626+}
2727+1628func GetFormat(filename string) Format {
1729 for format, extensions := range FileTypes {
1830 for _, extension := range extensions {
···11+{{ define "banner" }}
22+<div class="flex items-center justify-center mx-auto w-full bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-b drop-shadow-sm text-red-800 dark:text-red-200">
33+ <details class="group p-2">
44+ <summary class="list-none cursor-pointer">
55+ <div class="flex gap-4 items-center">
66+ <span class="group-open:hidden inline">{{ i "triangle-alert" "w-4 h-4" }}</span>
77+ <span class="hidden group-open:inline">{{ i "x" "w-4 h-4" }}</span>
88+99+ <span class="group-open:hidden inline">Some services that you administer require an update. Click to show more.</span>
1010+ <span class="hidden group-open:inline">Some services that you administer will have to be updated to be compatible with Tangled.</span>
1111+ </div>
1212+ </summary>
1313+1414+ {{ if .Registrations }}
1515+ <ul class="list-disc mx-12 my-2">
1616+ {{range .Registrations}}
1717+ <li>Knot: {{ .Domain }}</li>
1818+ {{ end }}
1919+ </ul>
2020+ {{ end }}
2121+2222+ {{ if .Spindles }}
2323+ <ul class="list-disc mx-12 my-2">
2424+ {{range .Spindles}}
2525+ <li>Spindle: {{ .Instance }}</li>
2626+ {{ end }}
2727+ </ul>
2828+ {{ end }}
2929+3030+ <div class="mx-6">
3131+ These services may not be fully accessible until upgraded.
3232+ <a class="underline text-red-800 dark:text-red-200"
3333+ href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md">
3434+ Click to read the upgrade guide</a>.
3535+ </div>
3636+ </details>
3737+</div>
3838+{{ end }}
+1-1
appview/pages/templates/errors/404.html
···1717 The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL.
1818 </p>
1919 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
2020- <a href="javascript:history.back()" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
2020+ <a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2">
2121 {{ i "arrow-left" "w-4 h-4" }}
2222 go back
2323 </a>
+4-4
appview/pages/templates/errors/500.html
···88 {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
99 </div>
1010 </div>
1111-1111+1212 <div class="space-y-4">
1313 <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
1414 500 — internal server error
···2424 <p class="mt-1">Our team has been automatically notified about this error.</p>
2525 </div>
2626 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
2727- <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
2727+ <button onclick="location.reload()" class="btn-create gap-2">
2828 {{ i "refresh-cw" "w-4 h-4" }}
2929 try again
3030 </button>
3131- <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
3131+ <a href="/" class="btn no-underline hover:no-underline gap-2">
3232 {{ i "home" "w-4 h-4" }}
3333 back to home
3434 </a>
···3636 </div>
3737 </div>
3838</div>
3939-{{ end }}3939+{{ end }}
+2-2
appview/pages/templates/errors/503.html
···1717 We were unable to reach the knot hosting this repository. The service may be temporarily unavailable.
1818 </p>
1919 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
2020- <button onclick="location.reload()" class="btn-create px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline hover:text-white">
2020+ <button onclick="location.reload()" class="btn-create gap-2">
2121 {{ i "refresh-cw" "w-4 h-4" }}
2222 try again
2323 </button>
2424- <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
2424+ <a href="/" class="btn gap-2 no-underline hover:no-underline">
2525 {{ i "arrow-left" "w-4 h-4" }}
2626 back to timeline
2727 </a>
+1-1
appview/pages/templates/errors/knot404.html
···1717 The repository you were looking for could not be found. The knot serving the repository may be unavailable.
1818 </p>
1919 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
2020- <a href="/" class="btn px-4 py-2 rounded flex items-center gap-2 no-underline hover:no-underline">
2020+ <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline">
2121 {{ i "arrow-left" "w-4 h-4" }}
2222 back to timeline
2323 </a>
···11-{{ define "knots/fragments/banner" }}
22-<div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm">
33- A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }})
44- that you administer is presently read-only. Consider upgrading this knot to
55- continue creating repositories on it.
66- <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/migrations/knot-1.7.0.md">Click to read the upgrade guide</a>.
77-</div>
88-{{ end }}
99-
···11{{ define "title" }}knots{{ end }}
2233{{ define "content" }}
44-<div class="px-6 py-4">
44+<div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom">
55 <h1 class="text-xl font-bold dark:text-white">Knots</h1>
66+ <span class="flex items-center gap-1">
77+ {{ i "book" "w-3 h-3" }}
88+ <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a>
99+ </span>
610</div>
711812<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
···1519{{ end }}
16201721{{ define "about" }}
1818- <section class="rounded flex flex-col gap-2">
1919- <p class="dark:text-gray-300">
2020- Knots are lightweight headless servers that enable users to host Git repositories with ease.
2121- Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โcommunityโ servers.
2222- When creating a repository, you can choose a knot to store it on.
2323- <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">
2424- Checkout the documentation if you're interested in self-hosting.
2525- </a>
2222+<section class="rounded">
2323+ <p class="text-gray-500 dark:text-gray-400">
2424+ Knots are lightweight headless servers that enable users to host Git repositories with ease.
2525+ When creating a repository, you can choose a knot to store it on.
2626 </p>
2727- </section>
2727+2828+2929+</section>
2830{{ end }}
29313032{{ define "list" }}
···11-{{ define "title" }} privacy policy {{ end }}
11+{{ define "title" }}privacy policy{{ end }}
22+23{{ define "content" }}
34<div class="max-w-4xl mx-auto px-4 py-8">
45 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
56 <div class="prose prose-gray dark:prose-invert max-w-none">
66- <h1>Privacy Policy</h1>
77-88- <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
99-1010- <p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
1111-1212- <h2>1. Information We Collect</h2>
1313-1414- <h3>Account Information</h3>
1515- <p>When you create an account, we collect:</p>
1616- <ul>
1717- <li>Your chosen username</li>
1818- <li>Email address</li>
1919- <li>Profile information you choose to provide</li>
2020- <li>Authentication data</li>
2121- </ul>
2222-2323- <h3>Content and Activity</h3>
2424- <p>We store:</p>
2525- <ul>
2626- <li>Code repositories and associated metadata</li>
2727- <li>Issues, pull requests, and comments</li>
2828- <li>Activity logs and usage patterns</li>
2929- <li>Public keys for authentication</li>
3030- </ul>
3131-3232- <h2>2. Data Location and Hosting</h2>
3333- <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
3434- <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
3535- <p class="text-blue-700 dark:text-blue-300">
3636- <strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
3737- </p>
3838- <ul class="text-blue-700 dark:text-blue-300 mt-2">
3939- <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
4040- <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
4141- <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
4242- </ul>
4343- </div>
4444-4545- <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
4646- <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
4747- <p class="text-yellow-700 dark:text-yellow-300">
4848- <strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
4949- </p>
5050- </div>
5151-5252- <h2>3. Third-Party Data Processors</h2>
5353- <p>We only share your data with the following third-party processors:</p>
5454-5555- <h3>Resend (Email Services)</h3>
5656- <ul>
5757- <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
5858- <li><strong>Data Shared:</strong> Email address and necessary message content</li>
5959- <li><strong>Location:</strong> EU-compliant email delivery service</li>
6060- </ul>
6161-6262- <h3>Cloudflare (Image Caching)</h3>
6363- <ul>
6464- <li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
6565- <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
6666- <li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
6767- </ul>
6868-6969- <h2>4. How We Use Your Information</h2>
7070- <p>We use your information to:</p>
7171- <ul>
7272- <li>Provide and maintain the Service</li>
7373- <li>Process your transactions and requests</li>
7474- <li>Send you technical notices and support messages</li>
7575- <li>Improve and develop new features</li>
7676- <li>Ensure security and prevent fraud</li>
7777- <li>Comply with legal obligations</li>
7878- </ul>
7979-8080- <h2>5. Data Sharing and Disclosure</h2>
8181- <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
8282- <ul>
8383- <li>With the third-party processors listed above</li>
8484- <li>When required by law or legal process</li>
8585- <li>To protect our rights, property, or safety, or that of our users</li>
8686- <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
8787- </ul>
8888-8989- <h2>6. Data Security</h2>
9090- <p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
9191-9292- <h2>7. Data Retention</h2>
9393- <p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
9494-9595- <h2>8. Your Rights</h2>
9696- <p>Under applicable data protection laws, you have the right to:</p>
9797- <ul>
9898- <li>Access your personal information</li>
9999- <li>Correct inaccurate information</li>
100100- <li>Request deletion of your information</li>
101101- <li>Object to processing of your information</li>
102102- <li>Data portability</li>
103103- <li>Withdraw consent (where applicable)</li>
104104- </ul>
105105-106106- <h2>9. Cookies and Tracking</h2>
107107- <p>We use cookies and similar technologies to:</p>
108108- <ul>
109109- <li>Maintain your login session</li>
110110- <li>Remember your preferences</li>
111111- <li>Analyze usage patterns to improve the Service</li>
112112- </ul>
113113- <p>You can control cookie settings through your browser preferences.</p>
114114-115115- <h2>10. Children's Privacy</h2>
116116- <p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
117117-118118- <h2>11. International Data Transfers</h2>
119119- <p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
120120-121121- <h2>12. Changes to This Privacy Policy</h2>
122122- <p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
123123-124124- <h2>13. Contact Information</h2>
125125- <p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
126126-127127- <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128128- <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129129- </div>
77+ {{ .Content }}
1308 </div>
1319 </div>
13210</div>
133133-{{ end }}
1111+{{ end }}
+2-62
appview/pages/templates/legal/terms.html
···44<div class="max-w-4xl mx-auto px-4 py-8">
55 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
66 <div class="prose prose-gray dark:prose-invert max-w-none">
77- <h1>Terms of Service</h1>
88-99- <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
1010-1111- <p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
1212-1313- <h2>1. Acceptance of Terms</h2>
1414- <p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
1515-1616- <h2>2. Account Registration</h2>
1717- <p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
1818-1919- <h2>3. Account Termination</h2>
2020- <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
2121- <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
2222- <p class="text-red-700 dark:text-red-300">
2323- <strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
2424- </p>
2525- <p class="text-red-700 dark:text-red-300 mt-2">
2626- Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
2727- </p>
2828- </div>
2929-3030- <h2>4. Acceptable Use</h2>
3131- <p>You agree not to use the Service to:</p>
3232- <ul>
3333- <li>Violate any applicable laws or regulations</li>
3434- <li>Infringe upon the rights of others</li>
3535- <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
3636- <li>Engage in spam, phishing, or other deceptive practices</li>
3737- <li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
3838- <li>Interfere with or disrupt the Service or servers connected to the Service</li>
3939- </ul>
4040-4141- <h2>5. Content and Intellectual Property</h2>
4242- <p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
4343-4444- <h2>6. Privacy</h2>
4545- <p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
4646-4747- <h2>7. Disclaimers</h2>
4848- <p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
4949-5050- <h2>8. Limitation of Liability</h2>
5151- <p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
5252-5353- <h2>9. Indemnification</h2>
5454- <p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
5555-5656- <h2>10. Governing Law</h2>
5757- <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
5858-5959- <h2>11. Changes to Terms</h2>
6060- <p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
6161-6262- <h2>12. Contact Information</h2>
6363- <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
6464-6565- <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
6666- <p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
6767- </div>
77+ {{ .Content }}
688 </div>
699 </div>
7010</div>
7171-{{ end }}
1111+{{ end }}
+1
appview/pages/templates/repo/blob.html
···7878 {{ end }}
7979 </div>
8080 {{ end }}
8181+ {{ template "fragments/multiline-select" }}
8182{{ end }}
···1111### message format
12121313```
1414-<service/top-level directory>: <affected package/directory>: <short summary of change>
1414+<service/top-level directory>/<affected package/directory>: <short summary of change>
151516161717Optional longer description can go here, if necessary. Explain what the
···2323Here are some examples:
24242525```
2626-appview: state: fix token expiry check in middleware
2626+appview/state: fix token expiry check in middleware
27272828The previous check did not account for clock drift, leading to premature
2929token invalidation.
3030```
31313232```
3333-knotserver: git/service: improve error checking in upload-pack
3333+knotserver/git/service: improve error checking in upload-pack
3434```
35353636
+53-12
docs/hacking.md
···4848redis-server
4949```
50505151-## running a knot
5151+## running knots and spindles
52525353An end-to-end knot setup requires setting up a machine with
5454`sshd`, `AuthorizedKeysCommand`, and git user, which is
5555quite cumbersome. So the nix flake provides a
5656`nixosConfiguration` to do so.
57575858-To begin, grab your DID from http://localhost:3000/settings.
5959-Then, set `TANGLED_VM_KNOT_OWNER` and
6060-`TANGLED_VM_SPINDLE_OWNER` to your DID.
5858+<details>
5959+ <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
6060+6161+ In order to build Tangled's dev VM on macOS, you will
6262+ first need to set up a Linux Nix builder. The recommended
6363+ way to do so is to run a [`darwin.linux-builder`
6464+ VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
6565+ and to register it in `nix.conf` as a builder for Linux
6666+ with the same architecture as your Mac (`linux-aarch64` if
6767+ you are using Apple Silicon).
6868+6969+ > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
7070+ > the tangled repo so that it doesn't conflict with the other VM. For example,
7171+ > you can do
7272+ >
7373+ > ```shell
7474+ > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
7575+ > ```
7676+ >
7777+ > to store the builder VM in a temporary dir.
7878+ >
7979+ > You should read and follow [all the other intructions][darwin builder vm] to
8080+ > avoid subtle problems.
8181+8282+ Alternatively, you can use any other method to set up a
8383+ Linux machine with `nix` installed that you can `sudo ssh`
8484+ into (in other words, root user on your Mac has to be able
8585+ to ssh into the Linux machine without entering a password)
8686+ and that has the same architecture as your Mac. See
8787+ [remote builder
8888+ instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
8989+ for how to register such a builder in `nix.conf`.
61906262-If you don't want to [set up a spindle](#running-a-spindle),
6363-you can use any placeholder value.
9191+ > WARNING: If you'd like to use
9292+ > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
9393+ > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
9494+ > ssh` works can be tricky. It seems to be [possible with
9595+ > Orbstack](https://github.com/orgs/orbstack/discussions/1669).
64966565-You can now start a lightweight NixOS VM like so:
9797+</details>
9898+9999+To begin, grab your DID from http://localhost:3000/settings.
100100+Then, set `TANGLED_VM_KNOT_OWNER` and
101101+`TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a
102102+lightweight NixOS VM like so:
6610367104```bash
68105nix run --impure .#vm
···74111with `ssh` exposed on port 2222.
7511276113Once the services are running, head to
7777-http://localhost:3000/knots and hit verify (and similarly,
7878-http://localhost:3000/spindles to verify your spindle). It
7979-should verify the ownership of the services instantly if
8080-everything went smoothly.
114114+http://localhost:3000/knots and hit verify. It should
115115+verify the ownership of the services instantly if everything
116116+went smoothly.
8111782118You can push repositories to this VM with this ssh config
83119block on your main machine:
···97133git push local-dev main
98134```
99135100100-## running a spindle
136136+### running a spindle
101137102138The above VM should already be running a spindle on
103139`localhost:6555`. Head to http://localhost:3000/spindles and
···119155# litecli has a nicer REPL interface:
120156litecli /var/lib/spindle/spindle.db
121157```
158158+159159+If for any reason you wish to disable either one of the
160160+services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set
161161+`services.tangled-spindle.enable` (or
162162+`services.tangled-knot.enable`) to `false`.
-35
docs/migrations/knot-1.7.0.md
···11-# Upgrading from v1.7.0
22-33-After v1.7.0, knot secrets have been deprecated. You no
44-longer need a secret from the appview to run a knot. All
55-authorized commands to knots are managed via [Inter-Service
66-Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
77-Knots will be read-only until upgraded.
88-99-Upgrading is quite easy, in essence:
1010-1111-- `KNOT_SERVER_SECRET` is no more, you can remove this
1212- environment variable entirely
1313-- `KNOT_SERVER_OWNER` is now required on boot, set this to
1414- your DID. You can find your DID in the
1515- [settings](https://tangled.sh/settings) page.
1616-- Restart your knot once you have replaced the environment
1717- variable
1818-- Head to the [knot dashboard](https://tangled.sh/knots) and
1919- hit the "retry" button to verify your knot. This simply
2020- writes a `sh.tangled.knot` record to your PDS.
2121-2222-## Nix
2323-2424-If you use the nix module, simply bump the flake to the
2525-latest revision, and change your config block like so:
2626-2727-```diff
2828- services.tangled-knot = {
2929- enable = true;
3030- server = {
3131-- secretFile = /path/to/secret;
3232-+ owner = "did:plc:foo";
3333- };
3434- };
3535-```
+60
docs/migrations.md
···11+# Migrations
22+33+This document is laid out in reverse-chronological order.
44+Newer migration guides are listed first, and older guides
55+are further down the page.
66+77+## Upgrading from v1.8.x
88+99+After v1.8.2, the HTTP API for knot and spindles have been
1010+deprecated and replaced with XRPC. Repositories on outdated
1111+knots will not be viewable from the appview. Upgrading is
1212+straightforward however.
1313+1414+For knots:
1515+1616+- Upgrade to latest tag (v1.9.0 or above)
1717+- Head to the [knot dashboard](https://tangled.sh/knots) and
1818+ hit the "retry" button to verify your knot
1919+2020+For spindles:
2121+2222+- Upgrade to latest tag (v1.9.0 or above)
2323+- Head to the [spindle
2424+ dashboard](https://tangled.sh/spindles) and hit the
2525+ "retry" button to verify your spindle
2626+2727+## Upgrading from v1.7.x
2828+2929+After v1.7.0, knot secrets have been deprecated. You no
3030+longer need a secret from the appview to run a knot. All
3131+authorized commands to knots are managed via [Inter-Service
3232+Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt).
3333+Knots will be read-only until upgraded.
3434+3535+Upgrading is quite easy, in essence:
3636+3737+- `KNOT_SERVER_SECRET` is no more, you can remove this
3838+ environment variable entirely
3939+- `KNOT_SERVER_OWNER` is now required on boot, set this to
4040+ your DID. You can find your DID in the
4141+ [settings](https://tangled.sh/settings) page.
4242+- Restart your knot once you have replaced the environment
4343+ variable
4444+- Head to the [knot dashboard](https://tangled.sh/knots) and
4545+ hit the "retry" button to verify your knot. This simply
4646+ writes a `sh.tangled.knot` record to your PDS.
4747+4848+If you use the nix module, simply bump the flake to the
4949+latest revision, and change your config block like so:
5050+5151+```diff
5252+ services.tangled-knot = {
5353+ enable = true;
5454+ server = {
5555+- secretFile = /path/to/secret;
5656++ owner = "did:plc:foo";
5757+ };
5858+ };
5959+```
6060+
+130-54
docs/spindle/pipeline.md
···11-# spindle pipeline manifest
11+# spindle pipelines
22+33+Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML.
44+55+The fields are:
2633-Spindle pipelines are defined under the `.tangled/workflows` directory in a
44-repo. Generally:
77+- [Trigger](#trigger): A **required** field that defines when a workflow should be triggered.
88+- [Engine](#engine): A **required** field that defines which engine a workflow should run on.
99+- [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned.
1010+- [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need.
1111+- [Environment](#environment): An **optional** field that allows you to define environment variables.
1212+- [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow.
51366-* Pipelines are defined in YAML.
77-* Workflows can run using different *engines*.
1414+## Trigger
81599-The most barebones workflow looks like this:
1616+The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields:
1717+1818+- `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values:
1919+ - `push`: The workflow should run every time a commit is pushed to the repository.
2020+ - `pull_request`: The workflow should run every time a pull request is made or updated.
2121+ - `manual`: The workflow can be triggered manually.
2222+- `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.
2323+2424+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:
10251126```yaml
1227when:
1313- - event: ["push"]
2828+ - event: ["push", "manual"]
2929+ branch: ["main", "develop"]
3030+ - event: ["pull_request"]
1431 branch: ["main"]
3232+```
15333434+## Engine
3535+3636+Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
3737+3838+- `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there.
3939+4040+Example:
4141+4242+```yaml
1643engine: "nixery"
4444+```
4545+4646+## Clone options
17471818-# optional
4848+When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields:
4949+5050+- `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default.
5151+- `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow.
5252+- `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default.
5353+5454+The default settings are:
5555+5656+```yaml
1957clone:
2058 skip: false
2121- depth: 50
2222- submodules: true
5959+ depth: 1
6060+ submodules: false
2361```
24622525-The `when` and `engine` fields are required, while every other aspect
2626-of how the definition is parsed is up to the engine. Currently, a spindle
2727-provides at least one of these built-in engines:
6363+## Dependencies
28642929-## `nixery`
6565+Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch.
30663131-The Nixery engine uses an instance of [Nixery](https://nixery.dev) to run
3232-steps that use dependencies from [Nixpkgs](https://github.com/NixOS/nixpkgs).
3333-3434-Here's an example that uses all fields:
6767+Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so:
35683669```yaml
3737-# build_and_test.yaml
3838-when:
3939- - event: ["push", "pull_request"]
4040- branch: ["main", "develop"]
4141- - event: ["manual"]
4242-4370dependencies:
4444- ## from nixpkgs
7171+ # nixpkgs
4572 nixpkgs:
4673 - nodejs
4747- ## custom registry
4848- git+https://tangled.sh/@oppi.li/statix:
4949- - statix
7474+ - go
7575+ # custom registry
7676+ git+https://tangled.sh/@example.com/my_pkg:
7777+ - my_pkg
7878+```
50795151-steps:
5252- - name: "Install dependencies"
5353- command: "npm install"
5454- environment:
5555- NODE_ENV: "development"
5656- CI: "true"
8080+Now these dependencies are available to use in your workflow!
57815858- - name: "Run linter"
5959- command: "npm run lint"
8282+## Environment
8383+8484+The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
8585+8686+Example:
8787+8888+```yaml
8989+environment:
9090+ GOOS: "linux"
9191+ GOARCH: "arm64"
9292+ NODE_ENV: "production"
9393+ MY_ENV_VAR: "MY_ENV_VALUE"
9494+```
60956161- - name: "Run tests"
6262- command: "npm test"
6363- environment:
6464- NODE_ENV: "test"
6565- JEST_WORKERS: "2"
9696+## Steps
66976767- - name: "Build application"
9898+The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields:
9999+100100+- `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing.
101101+- `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here.
102102+- `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.**
103103+104104+Example:
105105+106106+```yaml
107107+steps:
108108+ - name: "Build backend"
109109+ command: "go build"
110110+ environment:
111111+ GOOS: "darwin"
112112+ GOARCH: "arm64"
113113+ - name: "Build frontend"
68114 command: "npm run build"
69115 environment:
70116 NODE_ENV: "production"
117117+```
711187272-environment:
7373- BUILD_NUMBER: "123"
7474- GIT_BRANCH: "main"
119119+## Complete workflow
751207676-## current repository is cloned and checked out at the target ref
7777-## by default.
121121+```yaml
122122+# .tangled/workflows/build.yml
123123+124124+when:
125125+ - event: ["push", "manual"]
126126+ branch: ["main", "develop"]
127127+ - event: ["pull_request"]
128128+ branch: ["main"]
129129+130130+engine: "nixery"
131131+132132+# using the default values
78133clone:
79134 skip: false
8080- depth: 50
8181- submodules: true
8282-```
135135+ depth: 1
136136+ submodules: false
831378484-## git push options
138138+dependencies:
139139+ # nixpkgs
140140+ nixpkgs:
141141+ - nodejs
142142+ - go
143143+ # custom registry
144144+ git+https://tangled.sh/@example.com/my_pkg:
145145+ - my_pkg
851468686-These are push options that can be used with the `--push-option (-o)` flag of git push:
147147+environment:
148148+ GOOS: "linux"
149149+ GOARCH: "arm64"
150150+ NODE_ENV: "production"
151151+ MY_ENV_VAR: "MY_ENV_VALUE"
152152+153153+steps:
154154+ - name: "Build backend"
155155+ command: "go build"
156156+ environment:
157157+ GOOS: "darwin"
158158+ GOARCH: "arm64"
159159+ - name: "Build frontend"
160160+ command: "npm run build"
161161+ environment:
162162+ NODE_ENV: "production"
163163+```
871648888-- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
8989-- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
165165+If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
···11+package xrpc
22+33+import (
44+ "fmt"
55+ "net/http"
66+ "runtime/debug"
77+88+ "tangled.sh/tangled.sh/core/api/tangled"
99+)
1010+1111+// version is set during build time.
1212+var version string
1313+1414+func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) {
1515+ if version == "" {
1616+ info, ok := debug.ReadBuildInfo()
1717+ if !ok {
1818+ http.Error(w, "failed to read build info", http.StatusInternalServerError)
1919+ return
2020+ }
2121+2222+ var modVer string
2323+ var sha string
2424+ var modified bool
2525+2626+ for _, mod := range info.Deps {
2727+ if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
2828+ modVer = mod.Version
2929+ break
3030+ }
3131+ }
3232+3333+ for _, setting := range info.Settings {
3434+ switch setting.Key {
3535+ case "vcs.revision":
3636+ sha = setting.Value
3737+ case "vcs.modified":
3838+ modified = setting.Value == "true"
3939+ }
4040+ }
4141+4242+ if modVer == "" {
4343+ modVer = "unknown"
4444+ }
4545+4646+ if sha == "" {
4747+ version = modVer
4848+ } else if modified {
4949+ version = fmt.Sprintf("%s (%s with modifications)", modVer, sha)
5050+ } else {
5151+ version = fmt.Sprintf("%s (%s)", modVer, sha)
5252+ }
5353+ }
5454+5555+ response := tangled.KnotVersion_Output{
5656+ Version: version,
5757+ }
5858+5959+ writeJson(w, response)
6060+}
+67
knotserver/xrpc/xrpc.go
···44 "encoding/json"
55 "log/slog"
66 "net/http"
77+ "strings"
7899+ securejoin "github.com/cyphar/filepath-securejoin"
810 "tangled.sh/tangled.sh/core/api/tangled"
911 "tangled.sh/tangled.sh/core/idresolver"
1012 "tangled.sh/tangled.sh/core/jetstream"
···5052 // - we can calculate on PR submit/resubmit/gitRefUpdate etc.
5153 // - use ETags on clients to keep requests to a minimum
5254 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)
5555+5656+ // repo query endpoints (no auth required)
5757+ r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)
5858+ r.Get("/"+tangled.RepoLogNSID, x.RepoLog)
5959+ r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)
6060+ r.Get("/"+tangled.RepoTagsNSID, x.RepoTags)
6161+ r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob)
6262+ r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
6363+ r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
6464+ r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
6565+ r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
6666+ r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
6767+ r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
6868+6969+ // knot query endpoints (no auth required)
7070+ r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys)
7171+ r.Get("/"+tangled.KnotVersionNSID, x.Version)
7272+7373+ // service query endpoints (no auth required)
7474+ r.Get("/"+tangled.OwnerNSID, x.Owner)
7575+5376 return r
5477}
55787979+// parseRepoParam parses a repo parameter in 'did/repoName' format and returns
8080+// the full repository path on disk
8181+func (x *Xrpc) parseRepoParam(repo string) (string, error) {
8282+ if repo == "" {
8383+ return "", xrpcerr.NewXrpcError(
8484+ xrpcerr.WithTag("InvalidRequest"),
8585+ xrpcerr.WithMessage("missing repo parameter"),
8686+ )
8787+ }
8888+8989+ // Parse repo string (did/repoName format)
9090+ parts := strings.SplitN(repo, "/", 2)
9191+ if len(parts) != 2 {
9292+ return "", xrpcerr.NewXrpcError(
9393+ xrpcerr.WithTag("InvalidRequest"),
9494+ xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"),
9595+ )
9696+ }
9797+9898+ did := parts[0]
9999+ repoName := parts[1]
100100+101101+ // Construct repository path using the same logic as didPath
102102+ didRepoPath, err := securejoin.SecureJoin(did, repoName)
103103+ if err != nil {
104104+ return "", xrpcerr.RepoNotFoundError
105105+ }
106106+107107+ repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath)
108108+ if err != nil {
109109+ return "", xrpcerr.RepoNotFoundError
110110+ }
111111+112112+ return repoPath, nil
113113+}
114114+56115func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
57116 w.Header().Set("Content-Type", "application/json")
58117 w.WriteHeader(status)
59118 json.NewEncoder(w).Encode(e)
60119}
120120+121121+func writeJson(w http.ResponseWriter, response any) {
122122+ w.Header().Set("Content-Type", "application/json")
123123+ if err := json.NewEncoder(w).Encode(response); err != nil {
124124+ writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
125125+ return
126126+ }
127127+}
+158
legal/privacy.md
···11+# Privacy Policy
22+33+**Last updated:** January 15, 2025
44+55+This Privacy Policy describes how Tangled ("we," "us," or "our")
66+collects, uses, and shares your personal information when you use our
77+platform and services (the "Service").
88+99+## 1. Information We Collect
1010+1111+### Account Information
1212+1313+When you create an account, we collect:
1414+1515+- Your chosen username
1616+- Email address
1717+- Profile information you choose to provide
1818+- Authentication data
1919+2020+### Content and Activity
2121+2222+We store:
2323+2424+- Code repositories and associated metadata
2525+- Issues, pull requests, and comments
2626+- Activity logs and usage patterns
2727+- Public keys for authentication
2828+2929+## 2. Data Location and Hosting
3030+3131+### EU Data Hosting
3232+3333+**All Tangled service data is hosted within the European Union.**
3434+Specifically:
3535+3636+- **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS
3737+ (*.tngl.sh) are located in Finland
3838+- **Application Data:** All other service data is stored on EU-based
3939+ servers
4040+- **Data Processing:** All data processing occurs within EU
4141+ jurisdiction
4242+4343+### External PDS Notice
4444+4545+**Important:** If your account is hosted on Bluesky's PDS or other
4646+self-hosted Personal Data Servers (not *.tngl.sh), we do not control
4747+that data. The data protection, storage location, and privacy
4848+practices for such accounts are governed by the respective PDS
4949+provider's policies, not this Privacy Policy. We only control data
5050+processing within our own services and infrastructure.
5151+5252+## 3. Third-Party Data Processors
5353+5454+We only share your data with the following third-party processors:
5555+5656+### Resend (Email Services)
5757+5858+- **Purpose:** Sending transactional emails (account verification,
5959+ notifications)
6060+- **Data Shared:** Email address and necessary message content
6161+6262+### Cloudflare (Image Caching)
6363+6464+- **Purpose:** Caching and optimizing image delivery
6565+- **Data Shared:** Public images and associated metadata for caching
6666+ purposes
6767+6868+### Posthog (Usage Metrics Tracking)
6969+7070+- **Purpose:** Tracking usage and platform metrics
7171+- **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser
7272+ information
7373+7474+## 4. How We Use Your Information
7575+7676+We use your information to:
7777+7878+- Provide and maintain the Service
7979+- Process your transactions and requests
8080+- Send you technical notices and support messages
8181+- Improve and develop new features
8282+- Ensure security and prevent fraud
8383+- Comply with legal obligations
8484+8585+## 5. Data Sharing and Disclosure
8686+8787+We do not sell, trade, or rent your personal information. We may share
8888+your information only in the following circumstances:
8989+9090+- With the third-party processors listed above
9191+- When required by law or legal process
9292+- To protect our rights, property, or safety, or that of our users
9393+- In connection with a merger, acquisition, or sale of assets (with
9494+ appropriate protections)
9595+9696+## 6. Data Security
9797+9898+We implement appropriate technical and organizational measures to
9999+protect your personal information against unauthorized access,
100100+alteration, disclosure, or destruction. However, no method of
101101+transmission over the Internet is 100% secure.
102102+103103+## 7. Data Retention
104104+105105+We retain your personal information for as long as necessary to provide
106106+the Service and fulfill the purposes outlined in this Privacy Policy,
107107+unless a longer retention period is required by law.
108108+109109+## 8. Your Rights
110110+111111+Under applicable data protection laws, you have the right to:
112112+113113+- Access your personal information
114114+- Correct inaccurate information
115115+- Request deletion of your information
116116+- Object to processing of your information
117117+- Data portability
118118+- Withdraw consent (where applicable)
119119+120120+## 9. Cookies and Tracking
121121+122122+We use cookies and similar technologies to:
123123+124124+- Maintain your login session
125125+- Remember your preferences
126126+- Analyze usage patterns to improve the Service
127127+128128+You can control cookie settings through your browser preferences.
129129+130130+## 10. Children's Privacy
131131+132132+The Service is not intended for children under 16 years of age. We do
133133+not knowingly collect personal information from children under 16. If
134134+we become aware that we have collected such information, we will take
135135+steps to delete it.
136136+137137+## 11. International Data Transfers
138138+139139+While all our primary data processing occurs within the EU, some of our
140140+third-party processors may process data outside the EU. When this
141141+occurs, we ensure appropriate safeguards are in place, such as Standard
142142+Contractual Clauses or adequacy decisions.
143143+144144+## 12. Changes to This Privacy Policy
145145+146146+We may update this Privacy Policy from time to time. We will notify you
147147+of any changes by posting the new Privacy Policy on this page and
148148+updating the "Last updated" date.
149149+150150+## 13. Contact Information
151151+152152+If you have any questions about this Privacy Policy or wish to exercise
153153+your rights, please contact us through our platform or via email.
154154+155155+---
156156+157157+This Privacy Policy complies with the EU General Data Protection
158158+Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
···11+# Terms of Service
22+33+**Last updated:** January 15, 2025
44+55+Welcome to Tangled. These Terms of Service ("Terms") govern your access
66+to and use of the Tangled platform and services (the "Service")
77+operated by us ("Tangled," "we," "us," or "our").
88+99+## 1. Acceptance of Terms
1010+1111+By accessing or using our Service, you agree to be bound by these Terms.
1212+If you disagree with any part of these terms, then you may not access
1313+the Service.
1414+1515+## 2. Account Registration
1616+1717+To use certain features of the Service, you must register for an
1818+account. You agree to provide accurate, current, and complete
1919+information during the registration process and to update such
2020+information to keep it accurate, current, and complete.
2121+2222+## 3. Account Termination
2323+2424+> **Important Notice**
2525+>
2626+> **We reserve the right to terminate, suspend, or restrict access to
2727+> your account at any time, for any reason, or for no reason at all, at
2828+> our sole discretion.** This includes, but is not limited to,
2929+> termination for violation of these Terms, inappropriate conduct, spam,
3030+> abuse, or any other behavior we deem harmful to the Service or other
3131+> users.
3232+>
3333+> Account termination may result in the loss of access to your
3434+> repositories, data, and other content associated with your account. We
3535+> are not obligated to provide advance notice of termination, though we
3636+> may do so in our discretion.
3737+3838+## 4. Acceptable Use
3939+4040+You agree not to use the Service to:
4141+4242+- Violate any applicable laws or regulations
4343+- Infringe upon the rights of others
4444+- Upload, store, or share content that is illegal, harmful, threatening,
4545+ abusive, harassing, defamatory, vulgar, obscene, or otherwise
4646+ objectionable
4747+- Engage in spam, phishing, or other deceptive practices
4848+- Attempt to gain unauthorized access to the Service or other users'
4949+ accounts
5050+- Interfere with or disrupt the Service or servers connected to the
5151+ Service
5252+5353+## 5. Content and Intellectual Property
5454+5555+You retain ownership of the content you upload to the Service. By
5656+uploading content, you grant us a non-exclusive, worldwide, royalty-free
5757+license to use, reproduce, modify, and distribute your content as
5858+necessary to provide the Service.
5959+6060+## 6. Privacy
6161+6262+Your privacy is important to us. Please review our [Privacy
6363+Policy](/privacy), which also governs your use of the Service.
6464+6565+## 7. Disclaimers
6666+6767+The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make
6868+no warranties, expressed or implied, and hereby disclaim and negate all
6969+other warranties including without limitation, implied warranties or
7070+conditions of merchantability, fitness for a particular purpose, or
7171+non-infringement of intellectual property or other violation of rights.
7272+7373+## 8. Limitation of Liability
7474+7575+In no event shall Tangled, nor its directors, employees, partners,
7676+agents, suppliers, or affiliates, be liable for any indirect,
7777+incidental, special, consequential, or punitive damages, including
7878+without limitation, loss of profits, data, use, goodwill, or other
7979+intangible losses, resulting from your use of the Service.
8080+8181+## 9. Indemnification
8282+8383+You agree to defend, indemnify, and hold harmless Tangled and its
8484+affiliates, officers, directors, employees, and agents from and against
8585+any and all claims, damages, obligations, losses, liabilities, costs,
8686+or debt, and expenses (including attorney's fees).
8787+8888+## 10. Governing Law
8989+9090+These Terms shall be interpreted and governed by the laws of Finland,
9191+without regard to its conflict of law provisions.
9292+9393+## 11. Changes to Terms
9494+9595+We reserve the right to modify or replace these Terms at any time. If a
9696+revision is material, we will try to provide at least 30 days notice
9797+prior to any new terms taking effect.
9898+9999+## 12. Contact Information
100100+101101+If you have any questions about these Terms of Service, please contact
102102+us through our platform or via email.
103103+104104+---
105105+106106+These terms are effective as of the last updated date shown above and
107107+will remain in effect except with respect to any changes in their
108108+provisions in the future, which will be in effect immediately after
109109+being posted on this page.
···119119 // we have f1 and f2, combine them
120120 combined, err := combineFiles(f1, f2)
121121 if err != nil {
122122- fmt.Println(err)
122122+ // fmt.Println(err)
123123 }
124124125125 // combined can be nil commit 2 reverted all changes from commit 1
+2
spindle/config/config.go
···1717 Owner string `env:"OWNER, required"`
1818 Secrets Secrets `env:",prefix=SECRETS_"`
1919 LogDir string `env:"LOG_DIR, default=/var/log/spindle"`
2020+ QueueSize int `env:"QUEUE_SIZE, default=100"`
2121+ MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time
2022}
21232224func (s Server) Did() syntax.DID {