···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+package tangled
44+55+// schema: sh.tangled.repo.getDefaultBranch
66+77+import (
88+ "context"
99+1010+ "github.com/bluesky-social/indigo/lex/util"
1111+)
1212+1313+const (
1414+ RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch"
1515+)
1616+1717+// RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call.
1818+type RepoGetDefaultBranch_Output struct {
1919+ Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
2020+ // hash: Latest commit hash on default branch
2121+ Hash string `json:"hash" cborgen:"hash"`
2222+ // message: Latest commit message
2323+ Message *string `json:"message,omitempty" cborgen:"message,omitempty"`
2424+ // name: Default branch name
2525+ Name string `json:"name" cborgen:"name"`
2626+ // shortHash: Short commit hash
2727+ ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"`
2828+ // when: Timestamp of latest commit
2929+ When string `json:"when" cborgen:"when"`
3030+}
3131+3232+// RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema.
3333+type RepoGetDefaultBranch_Signature struct {
3434+ // email: Author email
3535+ Email string `json:"email" cborgen:"email"`
3636+ // name: Author name
3737+ Name string `json:"name" cborgen:"name"`
3838+ // when: Author timestamp
3939+ When string `json:"when" cborgen:"when"`
4040+}
4141+4242+// RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch".
4343+//
4444+// repo: Repository identifier in format 'did:plc:.../repoName'
4545+func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) {
4646+ var out RepoGetDefaultBranch_Output
4747+4848+ params := map[string]interface{}{}
4949+ params["repo"] = repo
5050+ if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil {
5151+ return nil, err
5252+ }
5353+5454+ return &out, nil
5555+}
+61
api/tangled/repolanguages.go
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+package tangled
44+55+// schema: sh.tangled.repo.languages
66+77+import (
88+ "context"
99+1010+ "github.com/bluesky-social/indigo/lex/util"
1111+)
1212+1313+const (
1414+ RepoLanguagesNSID = "sh.tangled.repo.languages"
1515+)
1616+1717+// RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema.
1818+type RepoLanguages_Language struct {
1919+ // color: Hex color code for this language
2020+ Color *string `json:"color,omitempty" cborgen:"color,omitempty"`
2121+ // extensions: File extensions associated with this language
2222+ Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"`
2323+ // fileCount: Number of files in this language
2424+ FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"`
2525+ // name: Programming language name
2626+ Name string `json:"name" cborgen:"name"`
2727+ // percentage: Percentage of total codebase (0-100)
2828+ Percentage int64 `json:"percentage" cborgen:"percentage"`
2929+ // size: Total size of files in this language (bytes)
3030+ Size int64 `json:"size" cborgen:"size"`
3131+}
3232+3333+// RepoLanguages_Output is the output of a sh.tangled.repo.languages call.
3434+type RepoLanguages_Output struct {
3535+ Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"`
3636+ // ref: The git reference used
3737+ Ref string `json:"ref" cborgen:"ref"`
3838+ // totalFiles: Total number of files analyzed
3939+ TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"`
4040+ // totalSize: Total size of all analyzed files in bytes
4141+ TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"`
4242+}
4343+4444+// RepoLanguages calls the XRPC method "sh.tangled.repo.languages".
4545+//
4646+// ref: Git reference (branch, tag, or commit SHA)
4747+// repo: Repository identifier in format 'did:plc:.../repoName'
4848+func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) {
4949+ var out RepoLanguages_Output
5050+5151+ params := map[string]interface{}{}
5252+ if ref != "" {
5353+ params["ref"] = ref
5454+ }
5555+ params["repo"] = repo
5656+ if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil {
5757+ return nil, err
5858+ }
5959+6060+ return &out, nil
6161+}
+45
api/tangled/repolog.go
···11+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
22+33+package tangled
44+55+// schema: sh.tangled.repo.log
66+77+import (
88+ "bytes"
99+ "context"
1010+1111+ "github.com/bluesky-social/indigo/lex/util"
1212+)
1313+1414+const (
1515+ RepoLogNSID = "sh.tangled.repo.log"
1616+)
1717+1818+// RepoLog calls the XRPC method "sh.tangled.repo.log".
1919+//
2020+// cursor: Pagination cursor (commit SHA)
2121+// limit: Maximum number of commits to return
2222+// path: Path to filter commits by
2323+// ref: Git reference (branch, tag, or commit SHA)
2424+// repo: Repository identifier in format 'did:plc:.../repoName'
2525+func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) {
2626+ buf := new(bytes.Buffer)
2727+2828+ params := map[string]interface{}{}
2929+ if cursor != "" {
3030+ params["cursor"] = cursor
3131+ }
3232+ if limit != 0 {
3333+ params["limit"] = limit
3434+ }
3535+ if path != "" {
3636+ params["path"] = path
3737+ }
3838+ params["ref"] = ref
3939+ params["repo"] = repo
4040+ if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil {
4141+ return nil, err
4242+ }
4343+4444+ return buf.Bytes(), nil
4545+}
+39
api/tangled/repotags.go
···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+}
+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 }
+410-453
appview/db/issues.go
···33import (
44 "database/sql"
55 "fmt"
66- mathrand "math/rand/v2"
66+ "maps"
77+ "slices"
88+ "sort"
79 "strings"
810 "time"
911···1315)
14161517type Issue struct {
1616- ID int64
1717- RepoAt syntax.ATURI
1818- OwnerDid string
1919- IssueId int
2020- Rkey string
2121- Created time.Time
2222- Title string
2323- Body string
2424- 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
25292630 // optionally, populate this when querying for reverse mappings
2731 // like comment counts, parent repo etc.
2828- Metadata *IssueMetadata
3232+ Comments []IssueComment
3333+ Repo *Repo
2934}
30353131-type IssueMetadata struct {
3232- CommentCount int
3333- Repo *Repo
3434- // 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))
3538}
36393737-type Comment struct {
3838- OwnerDid string
3939- RepoAt syntax.ATURI
4040- Rkey string
4141- Issue int
4242- CommentId int
4343- Body string
4444- Created *time.Time
4545- Deleted *time.Time
4646- 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+ }
4747}
48484949-func (i *Issue) AtUri() syntax.ATURI {
5050- 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
5959+}
6060+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
6565+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+ }
7575+ }
7676+7777+ for _, r := range replies {
7878+ parentAt := *r.ReplyTo
7979+ if parent, exists := toplevel[parentAt]; exists {
8080+ parent.Replies = append(parent.Replies, r)
8181+ }
8282+ }
8383+8484+ var listing []CommentListItem
8585+ for _, v := range toplevel {
8686+ listing = append(listing, *v)
8787+ }
8888+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+ })
100100+ }
101101+102102+ return listing
51103}
5210453105func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
···62114 }
6311564116 return Issue{
6565- RepoAt: syntax.ATURI(record.Repo),
6666- OwnerDid: did,
6767- Rkey: rkey,
6868- Created: created,
6969- Title: record.Title,
7070- Body: body,
7171- Open: true, // new issues are open by default
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
72124 }
73125}
741267575-func ResolveIssueFromAtUri(e Execer, issueUri syntax.ATURI) (syntax.ATURI, int, error) {
7676- ownerDid := issueUri.Authority().String()
7777- issueRkey := issueUri.RecordKey().String()
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
137137+}
781387979- var repoAt string
8080- var issueId int
139139+func (i *IssueComment) AtUri() syntax.ATURI {
140140+ return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
141141+}
811428282- query := `select repo_at, issue_id from issues where owner_did = ? and rkey = ?`
8383- err := e.QueryRow(query, ownerDid, issueRkey).Scan(&repoAt, &issueId)
8484- if err != nil {
8585- return "", 0, err
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,
86149 }
150150+}
871518888- return syntax.ATURI(repoAt), issueId, nil
152152+func (i *IssueComment) IsTopLevel() bool {
153153+ return i.ReplyTo == nil
89154}
901559191-func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (Comment, error) {
156156+func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
92157 created, err := time.Parse(time.RFC3339, record.CreatedAt)
93158 if err != nil {
94159 created = time.Now()
95160 }
9616197162 ownerDid := did
9898- if record.Owner != nil {
9999- ownerDid = *record.Owner
100100- }
101163102102- issueUri, err := syntax.ParseATURI(record.Issue)
103103- if err != nil {
104104- return Comment{}, err
105105- }
106106-107107- repoAt, issueId, err := ResolveIssueFromAtUri(e, issueUri)
108108- if err != nil {
109109- return Comment{}, err
164164+ if _, err = syntax.ParseATURI(record.Issue); err != nil {
165165+ return nil, err
110166 }
111167112112- comment := Comment{
113113- OwnerDid: ownerDid,
114114- RepoAt: repoAt,
115115- Rkey: rkey,
116116- Body: record.Body,
117117- Issue: issueId,
118118- CommentId: mathrand.IntN(1000000),
119119- Created: &created,
168168+ comment := IssueComment{
169169+ Did: ownerDid,
170170+ Rkey: rkey,
171171+ Body: record.Body,
172172+ IssueAt: record.Issue,
173173+ ReplyTo: record.ReplyTo,
174174+ Created: created,
120175 }
121176122122- return comment, nil
177177+ return &comment, nil
123178}
124179125125-func NewIssue(tx *sql.Tx, issue *Issue) error {
126126- defer tx.Rollback()
127127-180180+func PutIssue(tx *sql.Tx, issue *Issue) error {
181181+ // ensure sequence exists
128182 _, err := tx.Exec(`
129183 insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
130184 values (?, 1)
131131- `, issue.RepoAt)
185185+ `, issue.RepoAt)
132186 if err != nil {
133187 return err
134188 }
135189136136- var nextId int
137137- err = tx.QueryRow(`
138138- update repo_issue_seqs
139139- set next_issue_id = next_issue_id + 1
140140- where repo_at = ?
141141- returning next_issue_id - 1
142142- `, issue.RepoAt).Scan(&nextId)
143143- if err != nil {
190190+ issues, err := GetIssues(
191191+ tx,
192192+ FilterEq("did", issue.Did),
193193+ FilterEq("rkey", issue.Rkey),
194194+ )
195195+ switch {
196196+ case err != nil:
144197 return err
145145- }
146146-147147- issue.IssueId = nextId
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
207207+ }
148208149149- res, err := tx.Exec(`
150150- insert into issues (repo_at, owner_did, rkey, issue_at, issue_id, title, body)
151151- values (?, ?, ?, ?, ?, ?, ?)
152152- `, issue.RepoAt, issue.OwnerDid, issue.Rkey, issue.AtUri(), issue.IssueId, issue.Title, issue.Body)
153153- if err != nil {
154154- return err
209209+ issue.Id = existingIssue.Id
210210+ issue.IssueId = existingIssue.IssueId
211211+ return updateIssue(tx, issue)
155212 }
213213+}
156214157157- lastID, err := res.LastInsertId()
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)
158224 if err != nil {
159225 return err
160226 }
161161- issue.ID = lastID
162227163163- if err := tx.Commit(); err != nil {
164164- return err
165165- }
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)
166234167167- return nil
235235+ return row.Scan(&issue.Id, &issue.IssueId)
168236}
169237170170-func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
171171- var issueAt string
172172- err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt)
173173- return issueAt, err
174174-}
175175-176176-func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) {
177177- var ownerDid string
178178- err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid)
179179- return ownerDid, err
180180-}
181181-182182-func GetIssuesPaginated(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
183183- var issues []Issue
184184- openValue := 0
185185- if isOpen {
186186- openValue = 1
187187- }
188188-189189- rows, err := e.Query(
190190- `
191191- with numbered_issue as (
192192- select
193193- i.id,
194194- i.owner_did,
195195- i.rkey,
196196- i.issue_id,
197197- i.created,
198198- i.title,
199199- i.body,
200200- i.open,
201201- count(c.id) as comment_count,
202202- row_number() over (order by i.created desc) as row_num
203203- from
204204- issues i
205205- left join
206206- comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
207207- where
208208- i.repo_at = ? and i.open = ?
209209- group by
210210- i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
211211- )
212212- select
213213- id,
214214- owner_did,
215215- rkey,
216216- issue_id,
217217- created,
218218- title,
219219- body,
220220- open,
221221- comment_count
222222- from
223223- numbered_issue
224224- where
225225- row_num between ? and ?`,
226226- repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
227227- if err != nil {
228228- return nil, err
229229- }
230230- defer rows.Close()
231231-232232- for rows.Next() {
233233- var issue Issue
234234- var createdAt string
235235- var metadata IssueMetadata
236236- err := rows.Scan(&issue.ID, &issue.OwnerDid, &issue.Rkey, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
237237- if err != nil {
238238- return nil, err
239239- }
240240-241241- createdTime, err := time.Parse(time.RFC3339, createdAt)
242242- if err != nil {
243243- return nil, err
244244- }
245245- issue.Created = createdTime
246246- issue.Metadata = &metadata
247247-248248- issues = append(issues, issue)
249249- }
250250-251251- if err := rows.Err(); err != nil {
252252- return nil, err
253253- }
254254-255255- return issues, nil
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
256246}
257247258258-func GetIssuesWithLimit(e Execer, limit int, filters ...filter) ([]Issue, error) {
259259- 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
260250261251 var conditions []string
262252 var args []any
253253+263254 for _, filter := range filters {
264255 conditions = append(conditions, filter.Condition())
265256 args = append(args, filter.Arg()...)
···269260 if conditions != nil {
270261 whereClause = " where " + strings.Join(conditions, " and ")
271262 }
272272- limitClause := ""
273273- if limit != 0 {
274274- limitClause = fmt.Sprintf(" limit %d ", limit)
275275- }
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()
276270277271 query := fmt.Sprintf(
278278- `select
279279- i.id,
280280- i.owner_did,
281281- i.repo_at,
282282- i.issue_id,
283283- i.created,
284284- i.title,
285285- i.body,
286286- i.open
287287- from
288288- 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
289291 %s
290290- order by
291291- i.created desc
292292- %s`,
293293- whereClause, limitClause)
292292+ `,
293293+ whereClause,
294294+ pagination,
295295+ )
294296295297 rows, err := e.Query(query, args...)
296298 if err != nil {
297297- return nil, err
299299+ return nil, fmt.Errorf("failed to query issues table: %w", err)
298300 }
299301 defer rows.Close()
300302301303 for rows.Next() {
302304 var issue Issue
303303- var issueCreatedAt string
305305+ var createdAt string
306306+ var editedAt, deletedAt sql.Null[string]
307307+ var rowNum int64
304308 err := rows.Scan(
305305- &issue.ID,
306306- &issue.OwnerDid,
309309+ &issue.Id,
310310+ &issue.Did,
311311+ &issue.Rkey,
307312 &issue.RepoAt,
308313 &issue.IssueId,
309309- &issueCreatedAt,
310314 &issue.Title,
311315 &issue.Body,
312316 &issue.Open,
317317+ &createdAt,
318318+ &editedAt,
319319+ &deletedAt,
320320+ &rowNum,
313321 )
314322 if err != nil {
315315- return nil, err
323323+ return nil, fmt.Errorf("failed to scan issue: %w", err)
324324+ }
325325+326326+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
327327+ issue.Created = t
328328+ }
329329+330330+ if editedAt.Valid {
331331+ if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil {
332332+ issue.Edited = &t
333333+ }
316334 }
317335318318- issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
319319- if err != nil {
320320- return nil, err
336336+ if deletedAt.Valid {
337337+ if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil {
338338+ issue.Deleted = &t
339339+ }
321340 }
322322- issue.Created = issueCreatedTime
323341324324- issues = append(issues, issue)
342342+ atUri := issue.AtUri().String()
343343+ issueMap[atUri] = &issue
325344 }
326345327327- if err := rows.Err(); err != nil {
328328- return nil, err
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))
329350 }
330351331331- return issues, nil
332332-}
333333-334334-func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
335335- return GetIssuesWithLimit(e, 0, filters...)
336336-}
337337-338338-// timeframe here is directly passed into the sql query filter, and any
339339-// timeframe in the past should be negative; e.g.: "-3 months"
340340-func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
341341- var issues []Issue
342342-343343- rows, err := e.Query(
344344- `select
345345- i.id,
346346- i.owner_did,
347347- i.rkey,
348348- i.repo_at,
349349- i.issue_id,
350350- i.created,
351351- i.title,
352352- i.body,
353353- i.open,
354354- r.did,
355355- r.name,
356356- r.knot,
357357- r.rkey,
358358- r.created
359359- from
360360- issues i
361361- join
362362- repos r on i.repo_at = r.at_uri
363363- where
364364- i.owner_did = ? and i.created >= date ('now', ?)
365365- order by
366366- i.created desc`,
367367- ownerDid, timeframe)
352352+ repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts))
368353 if err != nil {
369369- return nil, err
354354+ return nil, fmt.Errorf("failed to build repo mappings: %w", err)
370355 }
371371- defer rows.Close()
372356373373- for rows.Next() {
374374- var issue Issue
375375- var issueCreatedAt, repoCreatedAt string
376376- var repo Repo
377377- err := rows.Scan(
378378- &issue.ID,
379379- &issue.OwnerDid,
380380- &issue.Rkey,
381381- &issue.RepoAt,
382382- &issue.IssueId,
383383- &issueCreatedAt,
384384- &issue.Title,
385385- &issue.Body,
386386- &issue.Open,
387387- &repo.Did,
388388- &repo.Name,
389389- &repo.Knot,
390390- &repo.Rkey,
391391- &repoCreatedAt,
392392- )
393393- if err != nil {
394394- return nil, err
395395- }
357357+ repoMap := make(map[string]*Repo)
358358+ for i := range repos {
359359+ repoMap[string(repos[i].RepoAt())] = &repos[i]
360360+ }
396361397397- issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
398398- if err != nil {
399399- 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)
400369 }
401401- issue.Created = issueCreatedTime
370370+ }
402371403403- repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
404404- if err != nil {
405405- return nil, err
406406- }
407407- 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+ }
408378409409- issue.Metadata = &IssueMetadata{
410410- 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])
411383 }
412412-413413- issues = append(issues, issue)
414384 }
415385416416- if err := rows.Err(); err != nil {
417417- return nil, err
386386+ var issues []Issue
387387+ for _, i := range issueMap {
388388+ issues = append(issues, *i)
418389 }
419390391391+ sort.Slice(issues, func(i, j int) bool {
392392+ return issues[i].Created.After(issues[j].Created)
393393+ })
394394+420395 return issues, nil
421396}
422397398398+func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
399399+ return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
400400+}
401401+423402func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
424403 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
425404 row := e.QueryRow(query, repoAt, issueId)
426405427406 var issue Issue
428407 var createdAt string
429429- 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)
430409 if err != nil {
431410 return nil, err
432411 }
···440419 return &issue, nil
441420}
442421443443-func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
444444- query := `select id, owner_did, rkey, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
445445- row := e.QueryRow(query, repoAt, issueId)
446446-447447- var issue Issue
448448- var createdAt string
449449- 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+ )
450453 if err != nil {
451451- return nil, nil, err
454454+ return 0, err
452455 }
453456454454- createdTime, err := time.Parse(time.RFC3339, createdAt)
457457+ id, err := result.LastInsertId()
455458 if err != nil {
456456- return nil, nil, err
459459+ return 0, err
457460 }
458458- issue.Created = createdTime
461461+462462+ return id, nil
463463+}
459464460460- comments, err := GetComments(e, repoAt, issueId)
461461- if err != nil {
462462- return nil, nil, err
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()...)
463471 }
464472465465- return &issue, comments, nil
466466-}
473473+ whereClause := ""
474474+ if conditions != nil {
475475+ whereClause = " where " + strings.Join(conditions, " and ")
476476+ }
467477468468-func NewIssueComment(e Execer, comment *Comment) error {
469469- query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
470470- _, err := e.Exec(
471471- query,
472472- comment.OwnerDid,
473473- comment.RepoAt,
474474- comment.Rkey,
475475- comment.Issue,
476476- comment.CommentId,
477477- comment.Body,
478478- )
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...)
479481 return err
480482}
481483482482-func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
483483- 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+ }
493493+494494+ whereClause := ""
495495+ if conditions != nil {
496496+ whereClause = " where " + strings.Join(conditions, " and ")
497497+ }
484498485485- rows, err := e.Query(`
499499+ query := fmt.Sprintf(`
486500 select
487487- owner_did,
488488- issue_id,
489489- comment_id,
501501+ id,
502502+ did,
490503 rkey,
504504+ issue_at,
505505+ reply_to,
491506 body,
492507 created,
493508 edited,
494509 deleted
495510 from
496496- comments
497497- where
498498- repo_at = ? and issue_id = ?
499499- order by
500500- created asc`,
501501- repoAt,
502502- issueId,
503503- )
504504- if err == sql.ErrNoRows {
505505- return []Comment{}, nil
506506- }
511511+ issue_comments
512512+ %s
513513+ `, whereClause)
514514+515515+ rows, err := e.Query(query, args...)
507516 if err != nil {
508517 return nil, err
509518 }
510510- defer rows.Close()
511519512520 for rows.Next() {
513513- var comment Comment
514514- var createdAt string
515515- var deletedAt, editedAt, rkey sql.NullString
516516- 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+ )
517535 if err != nil {
518536 return nil, err
519537 }
520538521521- createdAtTime, err := time.Parse(time.RFC3339, createdAt)
522522- if err != nil {
523523- 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
524542 }
525525- comment.Created = &createdAtTime
526543527527- if deletedAt.Valid {
528528- deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
529529- if err != nil {
530530- 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
531551 }
532532- comment.Deleted = &deletedTime
533552 }
534553535535- if editedAt.Valid {
536536- editedTime, err := time.Parse(time.RFC3339, editedAt.String)
537537- if err != nil {
538538- return nil, err
554554+ if deleted.Valid {
555555+ if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
556556+ comment.Deleted = &t
539557 }
540540- comment.Edited = &editedTime
541558 }
542559543543- if rkey.Valid {
544544- comment.Rkey = rkey.String
560560+ if replyTo.Valid {
561561+ comment.ReplyTo = &replyTo.V
545562 }
546563547564 comments = append(comments, comment)
548565 }
549566550550- if err := rows.Err(); err != nil {
567567+ if err = rows.Err(); err != nil {
551568 return nil, err
552569 }
553570554571 return comments, nil
555572}
556573557557-func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
558558- query := `
559559- select
560560- owner_did, body, rkey, created, deleted, edited
561561- from
562562- comments where repo_at = ? and issue_id = ? and comment_id = ?
563563- `
564564- row := e.QueryRow(query, repoAt, issueId, commentId)
565565-566566- var comment Comment
567567- var createdAt string
568568- var deletedAt, editedAt, rkey sql.NullString
569569- err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
570570- if err != nil {
571571- 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()...)
572580 }
573581574574- createdTime, err := time.Parse(time.RFC3339, createdAt)
575575- if err != nil {
576576- return nil, err
582582+ whereClause := ""
583583+ if conditions != nil {
584584+ whereClause = " where " + strings.Join(conditions, " and ")
577585 }
578578- comment.Created = &createdTime
579586580580- if deletedAt.Valid {
581581- deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
582582- if err != nil {
583583- return nil, err
584584- }
585585- comment.Deleted = &deletedTime
586586- }
587587+ query := fmt.Sprintf(`delete from issues %s`, whereClause)
588588+ _, err := e.Exec(query, args...)
589589+ return err
590590+}
587591588588- if editedAt.Valid {
589589- editedTime, err := time.Parse(time.RFC3339, editedAt.String)
590590- if err != nil {
591591- return nil, err
592592- }
593593- 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()...)
594598 }
595599596596- if rkey.Valid {
597597- comment.Rkey = rkey.String
600600+ whereClause := ""
601601+ if conditions != nil {
602602+ whereClause = " where " + strings.Join(conditions, " and ")
598603 }
599604600600- comment.RepoAt = repoAt
601601- comment.Issue = issueId
602602- comment.CommentId = commentId
603603-604604- return &comment, nil
605605-}
606606-607607-func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
608608- _, err := e.Exec(
609609- `
610610- update comments
611611- set body = ?,
612612- edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
613613- where repo_at = ? and issue_id = ? and comment_id = ?
614614- `, newBody, repoAt, issueId, commentId)
605605+ query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause)
606606+ _, err := e.Exec(query, args...)
615607 return err
616608}
617609618618-func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
619619- _, err := e.Exec(
620620- `
621621- update comments
622622- set body = "",
623623- deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
624624- where repo_at = ? and issue_id = ? and comment_id = ?
625625- `, repoAt, issueId, commentId)
626626- return err
627627-}
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+ }
628617629629-func UpdateCommentByRkey(e Execer, ownerDid, rkey, newBody string) error {
630630- _, err := e.Exec(
631631- `
632632- update comments
633633- set body = ?,
634634- edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
635635- where owner_did = ? and rkey = ?
636636- `, newBody, ownerDid, rkey)
637637- return err
638638-}
618618+ whereClause := ""
619619+ if conditions != nil {
620620+ whereClause = " where " + strings.Join(conditions, " and ")
621621+ }
639622640640-func DeleteCommentByRkey(e Execer, ownerDid, rkey string) error {
641641- _, err := e.Exec(
642642- `
643643- update comments
644644- set body = "",
645645- deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
646646- where owner_did = ? and rkey = ?
647647- `, ownerDid, rkey)
648648- return err
649649-}
650650-651651-func UpdateIssueByRkey(e Execer, ownerDid, rkey, title, body string) error {
652652- _, err := e.Exec(`update issues set title = ?, body = ? where owner_did = ? and rkey = ?`, title, body, ownerDid, rkey)
653653- return err
654654-}
655655-656656-func DeleteIssueByRkey(e Execer, ownerDid, rkey string) error {
657657- _, err := e.Exec(`delete from issues where owner_did = ? and rkey = ?`, ownerDid, rkey)
658658- return err
659659-}
660660-661661-func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
662662- _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId)
663663- return err
664664-}
665665-666666-func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
667667- _, 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...)
668625 return err
669626}
670627
+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 }}
···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+
···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