Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

lexicons: issue, issue state and comment

Custom lexicon for issues, issue state (open, closed) and issue comments.

The case with issue_at is a bit weird since we have a circular
dependency: the issue record requires the issue_id, and the issue entry
in the db requires the issue_at.

To resolve this we write to the db without the issue_at, fetch the
issue_id, create the issue record on the PDS, and then update the
issue_at (with SetIssueAt). It's not great, but whatever.

+337 -59
api/tangled/cbor_gen.go

This is a binary file and will not be displayed.

api/tangled/issuecomment.go

This is a binary file and will not be displayed.

api/tangled/issuestate.go

This is a binary file and will not be displayed.

api/tangled/repoissue.go

This is a binary file and will not be displayed.

api/tangled/stateclosed.go

This is a binary file and will not be displayed.

api/tangled/stateopen.go

This is a binary file and will not be displayed.

+3
appview/db/db.go
··· 73 73 issue_id integer not null unique, 74 74 title text not null, 75 75 body text not null, 76 + open integer not null default 1, 76 77 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 78 + issue_at text, 77 79 unique(repo_at, issue_id), 78 80 foreign key (repo_at) references repos(at_uri) on delete cascade 79 81 ); ··· 85 83 issue_id integer not null, 86 84 repo_at text not null, 87 85 comment_id integer not null, 86 + comment_at text not null, 88 87 body text not null, 89 88 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 90 89 unique(issue_id, comment_id),
+63 -11
appview/db/issues.go
··· 1 1 package db 2 2 3 - import "time" 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 4 7 5 8 type Issue struct { 6 9 RepoAt string 7 10 OwnerDid string 8 11 IssueId int 12 + IssueAt string 9 13 Created *time.Time 10 14 Title string 11 15 Body string ··· 19 15 type Comment struct { 20 16 OwnerDid string 21 17 RepoAt string 18 + CommentAt string 22 19 Issue int 23 20 CommentId int 24 21 Body string 25 22 Created *time.Time 26 23 } 27 24 28 - func (d *DB) NewIssue(issue *Issue) (int, error) { 25 + func (d *DB) NewIssue(issue *Issue) error { 29 26 tx, err := d.db.Begin() 30 27 if err != nil { 31 - return 0, err 28 + return err 32 29 } 33 30 defer tx.Rollback() 34 31 ··· 38 33 values (?, 1) 39 34 `, issue.RepoAt) 40 35 if err != nil { 41 - return 0, err 36 + return err 42 37 } 43 38 44 39 var nextId int ··· 49 44 returning next_issue_id - 1 50 45 `, issue.RepoAt).Scan(&nextId) 51 46 if err != nil { 52 - return 0, err 47 + return err 53 48 } 54 49 55 50 issue.IssueId = nextId ··· 59 54 values (?, ?, ?, ?, ?) 60 55 `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 61 56 if err != nil { 62 - return 0, err 57 + return err 63 58 } 64 59 65 60 if err := tx.Commit(); err != nil { 66 - return 0, err 61 + return err 67 62 } 68 63 69 - return nextId, nil 64 + return nil 65 + } 66 + 67 + func (d *DB) SetIssueAt(repoAt string, issueId int, issueAt string) error { 68 + _, err := d.db.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId) 69 + return err 70 + } 71 + 72 + func (d *DB) GetIssueAt(repoAt string, issueId int) (string, error) { 73 + var issueAt string 74 + err := d.db.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 75 + return issueAt, err 76 + } 77 + 78 + func (d *DB) GetIssueId(repoAt string) (int, error) { 79 + var issueId int 80 + err := d.db.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId) 81 + return issueId - 1, err 82 + } 83 + 84 + func (d *DB) GetIssueOwnerDid(repoAt string, issueId int) (string, error) { 85 + var ownerDid string 86 + err := d.db.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 87 + return ownerDid, err 70 88 } 71 89 72 90 func (d *DB) GetIssues(repoAt string) ([]Issue, error) { ··· 125 97 return issues, nil 126 98 } 127 99 100 + func (d *DB) GetIssue(repoAt string, issueId int) (*Issue, error) { 101 + query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 102 + row := d.db.QueryRow(query, repoAt, issueId) 103 + 104 + var issue Issue 105 + var createdAt string 106 + err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 107 + if err != nil { 108 + return nil, err 109 + } 110 + 111 + createdTime, err := time.Parse(time.RFC3339, createdAt) 112 + if err != nil { 113 + return nil, err 114 + } 115 + issue.Created = &createdTime 116 + 117 + return &issue, nil 118 + } 119 + 128 120 func (d *DB) GetIssueWithComments(repoAt string, issueId int) (*Issue, []Comment, error) { 129 121 query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 130 122 row := d.db.QueryRow(query, repoAt, issueId) ··· 171 123 } 172 124 173 125 func (d *DB) NewComment(comment *Comment) error { 174 - query := `insert into comments (owner_did, repo_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?)` 126 + query := `insert into comments (owner_did, repo_at, comment_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 175 127 _, err := d.db.Exec( 176 128 query, 177 129 comment.OwnerDid, 178 130 comment.RepoAt, 131 + comment.CommentAt, 179 132 comment.Issue, 180 133 comment.CommentId, 181 134 comment.Body, ··· 187 138 func (d *DB) GetComments(repoAt string, issueId int) ([]Comment, error) { 188 139 var comments []Comment 189 140 190 - rows, err := d.db.Query(`select owner_did, issue_id, comment_id, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId) 141 + rows, err := d.db.Query(`select owner_did, issue_id, comment_id, comment_at, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId) 142 + if err == sql.ErrNoRows { 143 + return []Comment{}, nil 144 + } 191 145 if err != nil { 192 146 return nil, err 193 147 } ··· 199 147 for rows.Next() { 200 148 var comment Comment 201 149 var createdAt string 202 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.Body, &createdAt) 150 + err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.CommentAt, &comment.Body, &createdAt) 203 151 if err != nil { 204 152 return nil, err 205 153 }
+27 -35
appview/pages/templates/timeline.html
··· 4 4 <h1>Timeline</h1> 5 5 6 6 {{ range .Timeline }} 7 - <div class="relative 8 - px-4 9 - py-2 10 - border-l 11 - border-black 12 - before:content-[''] 13 - before:absolute 14 - before:w-1 15 - before:h-1 16 - before:bg-black 17 - before:rounded-full 18 - before:left-[-2.2px] 19 - before:top-1/2 20 - before:-translate-y-1/2 21 - "> 22 7 {{ if .Repo }} 23 - {{ $userHandle := index $.DidHandleMap .Repo.Did }} 24 - <div class="flex items-center"> 25 - <p class="text-gray-600"> 26 - <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle }}</a> 27 - created 28 - <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 29 - <time class="text-gray-700">{{ .Repo.Created | timeFmt }}</time> 30 - </p> 31 - </div> 8 + <div class="border border-black p-4 m-2 bg-white w-1/2"> 9 + <div class="flex items-center"> 10 + <div class="text-sm text-gray-600"> 11 + {{ .Repo.Did }} created 12 + </div> 13 + div> 14 + <div class="px-3">{{ .Repo.Name }}</div> 15 + </div> 16 + 17 + <time class="text-sm text-gray-700" 18 + >{{ .Repo.Created | timeFmt }}</time 19 + > 20 + </div> 32 21 {{ else if .Follow }} 33 - {{ $userHandle := index $.DidHandleMap .Follow.UserDid }} 34 - {{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }} 35 - <div class="flex items-center"> 36 - <p class="text-gray-600"> 37 - <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle }}</a> 38 - followed 39 - <a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle }}</a> 40 - <time class="text-gray-700">{{ .Follow.FollowedAt | timeFmt }}</time> 41 - </p> 42 - </div> 22 + <div class="border border-black p-4 m-2 bg-white w-1/2"> 23 + <div class="flex items-center"> 24 + <div class="text-sm text-gray-600"> 25 + {{ .Follow.UserDid }} followed 26 + </div> 27 + <div class="text-sm text-gray-800"> 28 + {{ .Follow.SubjectDid }} 29 + </div> 30 + </div> 31 + 32 + <time class="text-sm text-gray-700" 33 + >{{ .Follow.FollowedAt | timeFmt }}</time 34 + > 35 + </div> 43 36 {{ end }} 44 - </div> 45 37 {{ end }} 46 38 47 39 {{ end }}
+106 -5
appview/state/repo.go
··· 11 11 "path" 12 12 "strconv" 13 13 "strings" 14 + "time" 14 15 15 16 "github.com/bluesky-social/indigo/atproto/identity" 16 17 securejoin "github.com/cyphar/filepath-securejoin" 17 18 "github.com/go-chi/chi/v5" 19 + "github.com/sotangled/tangled/api/tangled" 18 20 "github.com/sotangled/tangled/appview/auth" 19 21 "github.com/sotangled/tangled/appview/db" 20 22 "github.com/sotangled/tangled/appview/pages" 21 23 "github.com/sotangled/tangled/types" 24 + 25 + comatproto "github.com/bluesky-social/indigo/api/atproto" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 22 27 ) 23 28 24 29 func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { ··· 569 564 return 570 565 } 571 566 567 + issue, err := s.db.GetIssue(f.RepoAt, issueIdInt) 568 + if err != nil { 569 + log.Println("failed to get issue", err) 570 + s.pages.Notice(w, "issues", "Failed to close issue. Try again later.") 571 + return 572 + } 573 + 574 + // TODO: make this more granular 572 575 if user.Did == f.OwnerDid() { 576 + 577 + closed := tangled.RepoIssueStateClosed 578 + 579 + client, _ := s.auth.AuthorizedClient(r) 580 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 581 + Collection: tangled.RepoIssueStateNSID, 582 + Repo: issue.OwnerDid, 583 + Rkey: s.TID(), 584 + Record: &lexutil.LexiconTypeDecoder{ 585 + Val: &tangled.RepoIssueState{ 586 + Issue: issue.IssueAt, 587 + State: &closed, 588 + }, 589 + }, 590 + }) 591 + 592 + if err != nil { 593 + log.Println("failed to update issue state", err) 594 + s.pages.Notice(w, "issues", "Failed to close issue. Try again later.") 595 + return 596 + } 597 + 573 598 err := s.db.CloseIssue(f.RepoAt, issueIdInt) 574 599 if err != nil { 575 600 log.Println("failed to close issue", err) 576 601 s.pages.Notice(w, "issues", "Failed to close issue. Try again later.") 577 602 return 578 603 } 604 + 579 605 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 580 606 return 581 607 } else { ··· 673 637 } 674 638 675 639 commentId := rand.IntN(1000000) 676 - fmt.Println(commentId) 677 - fmt.Println("comment id", commentId) 678 640 679 641 err := s.db.NewComment(&db.Comment{ 680 642 OwnerDid: user.Did, ··· 680 646 Issue: issueIdInt, 681 647 CommentId: commentId, 682 648 Body: body, 649 + }) 650 + if err != nil { 651 + log.Println("failed to create comment", err) 652 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 653 + return 654 + } 655 + 656 + createdAt := time.Now().Format(time.RFC3339) 657 + commentIdInt64 := int64(commentId) 658 + ownerDid := user.Did 659 + issueAt, err := s.db.GetIssueAt(f.RepoAt, issueIdInt) 660 + if err != nil { 661 + log.Println("failed to get issue at", err) 662 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 663 + return 664 + } 665 + 666 + client, _ := s.auth.AuthorizedClient(r) 667 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 668 + Collection: tangled.RepoIssueCommentNSID, 669 + Repo: user.Did, 670 + Rkey: s.TID(), 671 + Record: &lexutil.LexiconTypeDecoder{ 672 + Val: &tangled.RepoIssueComment{ 673 + Repo: &f.RepoAt, 674 + Issue: issueAt, 675 + CommentId: &commentIdInt64, 676 + Owner: &ownerDid, 677 + Body: &body, 678 + CreatedAt: &createdAt, 679 + }, 680 + }, 683 681 }) 684 682 if err != nil { 685 683 log.Println("failed to create comment", err) ··· 777 711 body := r.FormValue("body") 778 712 779 713 if title == "" || body == "" { 780 - s.pages.Notice(w, "issue", "Title and body are required") 714 + s.pages.Notice(w, "issues", "Title and body are required") 781 715 return 782 716 } 783 717 784 - issueId, err := s.db.NewIssue(&db.Issue{ 718 + err = s.db.NewIssue(&db.Issue{ 785 719 RepoAt: f.RepoAt, 786 720 Title: title, 787 721 Body: body, ··· 789 723 }) 790 724 if err != nil { 791 725 log.Println("failed to create issue", err) 792 - s.pages.Notice(w, "issue", "Failed to create issue.") 726 + s.pages.Notice(w, "issues", "Failed to create issue.") 727 + return 728 + } 729 + 730 + issueId, err := s.db.GetIssueId(f.RepoAt) 731 + if err != nil { 732 + log.Println("failed to get issue id", err) 733 + s.pages.Notice(w, "issues", "Failed to create issue.") 734 + return 735 + } 736 + 737 + client, _ := s.auth.AuthorizedClient(r) 738 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 739 + Collection: tangled.RepoIssueNSID, 740 + Repo: user.Did, 741 + Rkey: s.TID(), 742 + Record: &lexutil.LexiconTypeDecoder{ 743 + Val: &tangled.RepoIssue{ 744 + Repo: f.RepoAt, 745 + Title: title, 746 + Body: &body, 747 + Owner: user.Did, 748 + IssueId: int64(issueId), 749 + }, 750 + }, 751 + }) 752 + if err != nil { 753 + log.Println("failed to create issue", err) 754 + s.pages.Notice(w, "issues", "Failed to create issue.") 755 + return 756 + } 757 + 758 + err = s.db.SetIssueAt(f.RepoAt, issueId, resp.Uri) 759 + if err != nil { 760 + log.Println("failed to set issue at", err) 761 + s.pages.Notice(w, "issues", "Failed to create issue.") 793 762 return 794 763 } 795 764
+3
cmd/gen.go
··· 18 18 shtangled.KnotMember{}, 19 19 shtangled.GraphFollow{}, 20 20 shtangled.Repo{}, 21 + shtangled.RepoIssue{}, 22 + shtangled.RepoIssueState{}, 23 + shtangled.RepoIssueComment{}, 21 24 ); err != nil { 22 25 panic(err) 23 26 }
-1
go.mod
··· 24 24 github.com/russross/blackfriday/v2 v2.1.0 25 25 github.com/sethvargo/go-envconfig v1.1.0 26 26 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 27 - golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 28 27 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 29 28 ) 30 29
-2
go.sum
··· 307 307 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 308 308 golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 309 309 golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 310 - golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= 311 - golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 312 310 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 313 311 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 314 312 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+12
lexicons/issue/closed.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.issue.state.closed", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "token", 9 + "description": "closed issue" 10 + } 11 + } 12 + }
+40
lexicons/issue/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.issue.comment", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["issue"], 13 + "properties": { 14 + "issue": { 15 + "type": "string", 16 + "format": "at-uri" 17 + }, 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "commentId": { 23 + "type": "integer" 24 + }, 25 + "owner": { 26 + "type": "string", 27 + "format": "did" 28 + }, 29 + "body": { 30 + "type": "string" 31 + }, 32 + "createdAt": { 33 + "type": "string", 34 + "format": "datetime" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+39
lexicons/issue/issue.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.issue", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["repo", "issueId", "owner", "title"], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "format": "at-uri" 17 + }, 18 + "issueId": { 19 + "type": "integer" 20 + }, 21 + "owner": { 22 + "type": "string", 23 + "format": "did" 24 + }, 25 + "title": { 26 + "type": "string" 27 + }, 28 + "body": { 29 + "type": "string" 30 + }, 31 + "createdAt": { 32 + "type": "string", 33 + "format": "datetime" 34 + } 35 + } 36 + } 37 + } 38 + } 39 + }
+12
lexicons/issue/open.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.issue.state.open", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "token", 9 + "description": "open issue" 10 + } 11 + } 12 + }
+31
lexicons/issue/state.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.issue.state", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["issue"], 13 + "properties": { 14 + "issue": { 15 + "type": "string", 16 + "format": "at-uri" 17 + }, 18 + "state": { 19 + "type": "string", 20 + "description": "state of the issue", 21 + "knownValues": [ 22 + "sh.tangled.repo.issue.state.open", 23 + "sh.tangled.repo.issue.state.closed" 24 + ], 25 + "default": "sh.tangled.repo.issue.state.open" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+1 -5
lexicons/repo.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "name", 14 - "knot", 15 - "owner" 16 - ], 12 + "required": ["name", "knot", "owner"], 17 13 "properties": { 18 14 "name": { 19 15 "type": "string",