···1+package models
2+3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+8+ "github.com/bluesky-social/indigo/atproto/syntax"
9+ "github.com/whyrusleeping/cbor-gen"
10+ "tangled.org/core/api/tangled"
11+)
12+13+type Comment struct {
14+ Id int64
15+ Did syntax.DID
16+ Collection syntax.NSID
17+ Rkey string
18+ Subject syntax.ATURI
19+ ReplyTo *syntax.ATURI
20+ Body string
21+ Created time.Time
22+ Edited *time.Time
23+ Deleted *time.Time
24+ Mentions []syntax.DID
25+ References []syntax.ATURI
26+ PullSubmissionId *int
27+}
28+29+func (c *Comment) AtUri() syntax.ATURI {
30+ return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey))
31+}
32+33+func (c *Comment) AsRecord() typegen.CBORMarshaler {
34+ mentions := make([]string, len(c.Mentions))
35+ for i, did := range c.Mentions {
36+ mentions[i] = string(did)
37+ }
38+ references := make([]string, len(c.References))
39+ for i, uri := range c.References {
40+ references[i] = string(uri)
41+ }
42+ var replyTo *string
43+ if c.ReplyTo != nil {
44+ replyToStr := c.ReplyTo.String()
45+ replyTo = &replyToStr
46+ }
47+ switch c.Collection {
48+ case tangled.RepoIssueCommentNSID:
49+ return &tangled.RepoIssueComment{
50+ Issue: c.Subject.String(),
51+ Body: c.Body,
52+ CreatedAt: c.Created.Format(time.RFC3339),
53+ ReplyTo: replyTo,
54+ Mentions: mentions,
55+ References: references,
56+ }
57+ case tangled.RepoPullCommentNSID:
58+ return &tangled.RepoPullComment{
59+ Pull: c.Subject.String(),
60+ Body: c.Body,
61+ CreatedAt: c.Created.Format(time.RFC3339),
62+ Mentions: mentions,
63+ References: references,
64+ }
65+ default: // default to CommentNSID
66+ return &tangled.Comment{
67+ Subject: c.Subject.String(),
68+ Body: c.Body,
69+ CreatedAt: c.Created.Format(time.RFC3339),
70+ ReplyTo: replyTo,
71+ Mentions: mentions,
72+ References: references,
73+ }
74+ }
75+}
76+77+func (c *Comment) IsTopLevel() bool {
78+ return c.ReplyTo == nil
79+}
80+81+func (c *Comment) IsReply() bool {
82+ return c.ReplyTo != nil
83+}
84+85+func (c *Comment) Validate() error {
86+ // TODO: sanitize the body and then trim space
87+ if sb := strings.TrimSpace(c.Body); sb == "" {
88+ return fmt.Errorf("body is empty after HTML sanitization")
89+ }
90+91+ // if it's for PR, PullSubmissionId should not be nil
92+ if c.Subject.Collection().String() == tangled.RepoPullNSID {
93+ if c.PullSubmissionId == nil {
94+ return fmt.Errorf("PullSubmissionId should not be nil")
95+ }
96+ }
97+ return nil
98+}
99+100+func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, record tangled.Comment) (*Comment, error) {
101+ created, err := time.Parse(time.RFC3339, record.CreatedAt)
102+ if err != nil {
103+ created = time.Now()
104+ }
105+106+ if _, err = syntax.ParseATURI(record.Subject); err != nil {
107+ return nil, err
108+ }
109+110+ i := record
111+ mentions := make([]syntax.DID, len(record.Mentions))
112+ for i, did := range record.Mentions {
113+ mentions[i] = syntax.DID(did)
114+ }
115+ references := make([]syntax.ATURI, len(record.References))
116+ for i, uri := range i.References {
117+ references[i] = syntax.ATURI(uri)
118+ }
119+ var replyTo *syntax.ATURI
120+ if record.ReplyTo != nil {
121+ replyToAtUri := syntax.ATURI(*record.ReplyTo)
122+ replyTo = &replyToAtUri
123+ }
124+125+ comment := Comment{
126+ Did: did,
127+ Collection: tangled.CommentNSID,
128+ Rkey: rkey.String(),
129+ Body: record.Body,
130+ Subject: syntax.ATURI(record.Subject),
131+ ReplyTo: replyTo,
132+ Created: created,
133+ Mentions: mentions,
134+ References: references,
135+ }
136+137+ return &comment, nil
138+}
+8-89
appview/models/issue.go
···2627 // optionally, populate this when querying for reverse mappings
28 // like comment counts, parent repo etc.
29- Comments []IssueComment
30 Labels LabelState
31 Repo *Repo
32}
···62}
6364type CommentListItem struct {
65- Self *IssueComment
66- Replies []*IssueComment
67}
6869func (it *CommentListItem) Participants() []syntax.DID {
···8889func (i *Issue) CommentList() []CommentListItem {
90 // Create a map to quickly find comments by their aturi
91- toplevel := make(map[string]*CommentListItem)
92- var replies []*IssueComment
9394 // collect top level comments into the map
95 for _, comment := range i.Comments {
96 if comment.IsTopLevel() {
97- toplevel[comment.AtUri().String()] = &CommentListItem{
98 Self: &comment,
99 }
100 } else {
···115 }
116117 // sort everything
118- sortFunc := func(a, b *IssueComment) bool {
119 return a.Created.Before(b.Created)
120 }
121 sort.Slice(listing, func(i, j int) bool {
···144 addParticipant(i.Did)
145146 for _, c := range i.Comments {
147- addParticipant(c.Did)
148 }
149150 return participants
···171 Open: true, // new issues are open by default
172 }
173}
174-175-type IssueComment struct {
176- Id int64
177- Did string
178- Rkey string
179- IssueAt string
180- ReplyTo *string
181- Body string
182- Created time.Time
183- Edited *time.Time
184- Deleted *time.Time
185- Mentions []syntax.DID
186- References []syntax.ATURI
187-}
188-189-func (i *IssueComment) AtUri() syntax.ATURI {
190- return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
191-}
192-193-func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
194- mentions := make([]string, len(i.Mentions))
195- for i, did := range i.Mentions {
196- mentions[i] = string(did)
197- }
198- references := make([]string, len(i.References))
199- for i, uri := range i.References {
200- references[i] = string(uri)
201- }
202- return tangled.RepoIssueComment{
203- Body: i.Body,
204- Issue: i.IssueAt,
205- CreatedAt: i.Created.Format(time.RFC3339),
206- ReplyTo: i.ReplyTo,
207- Mentions: mentions,
208- References: references,
209- }
210-}
211-212-func (i *IssueComment) IsTopLevel() bool {
213- return i.ReplyTo == nil
214-}
215-216-func (i *IssueComment) IsReply() bool {
217- return i.ReplyTo != nil
218-}
219-220-func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
221- created, err := time.Parse(time.RFC3339, record.CreatedAt)
222- if err != nil {
223- created = time.Now()
224- }
225-226- ownerDid := did
227-228- if _, err = syntax.ParseATURI(record.Issue); err != nil {
229- return nil, err
230- }
231-232- i := record
233- mentions := make([]syntax.DID, len(record.Mentions))
234- for i, did := range record.Mentions {
235- mentions[i] = syntax.DID(did)
236- }
237- references := make([]syntax.ATURI, len(record.References))
238- for i, uri := range i.References {
239- references[i] = syntax.ATURI(uri)
240- }
241-242- comment := IssueComment{
243- Did: ownerDid,
244- Rkey: rkey,
245- Body: record.Body,
246- IssueAt: record.Issue,
247- ReplyTo: record.ReplyTo,
248- Created: created,
249- Mentions: mentions,
250- References: references,
251- }
252-253- return &comment, nil
254-}
···2627 // optionally, populate this when querying for reverse mappings
28 // like comment counts, parent repo etc.
29+ Comments []Comment
30 Labels LabelState
31 Repo *Repo
32}
···62}
6364type CommentListItem struct {
65+ Self *Comment
66+ Replies []*Comment
67}
6869func (it *CommentListItem) Participants() []syntax.DID {
···8889func (i *Issue) CommentList() []CommentListItem {
90 // Create a map to quickly find comments by their aturi
91+ toplevel := make(map[syntax.ATURI]*CommentListItem)
92+ var replies []*Comment
9394 // collect top level comments into the map
95 for _, comment := range i.Comments {
96 if comment.IsTopLevel() {
97+ toplevel[comment.AtUri()] = &CommentListItem{
98 Self: &comment,
99 }
100 } else {
···115 }
116117 // sort everything
118+ sortFunc := func(a, b *Comment) bool {
119 return a.Created.Before(b.Created)
120 }
121 sort.Slice(listing, func(i, j int) bool {
···144 addParticipant(i.Did)
145146 for _, c := range i.Comments {
147+ addParticipant(c.Did.String())
148 }
149150 return participants
···171 Open: true, // new issues are open by default
172 }
173}
000000000000000000000000000000000000000000000000000000000000000000000000000000000
+2-28
appview/models/pull.go
···138 RoundNumber int
139 Patch string
140 Combined string
141- Comments []PullComment
142 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
143144 // meta
145 Created time.Time
146-}
147-148-type PullComment struct {
149- // ids
150- ID int
151- PullId int
152- SubmissionId int
153-154- // at ids
155- RepoAt string
156- OwnerDid string
157- CommentAt string
158-159- // content
160- Body string
161-162- // meta
163- Mentions []syntax.DID
164- References []syntax.ATURI
165-166- // meta
167- Created time.Time
168-}
169-170-func (p *PullComment) AtUri() syntax.ATURI {
171- return syntax.ATURI(p.CommentAt)
172}
173174func (p *Pull) TotalComments() int {
···279 addParticipant(s.PullAt.Authority().String())
280281 for _, c := range s.Comments {
282- addParticipant(c.OwnerDid)
283 }
284285 return participants
···138 RoundNumber int
139 Patch string
140 Combined string
141+ Comments []Comment
142 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
143144 // meta
145 Created time.Time
00000000000000000000000000146}
147148func (p *Pull) TotalComments() int {
···253 addParticipant(s.PullAt.Authority().String())
254255 for _, c := range s.Comments {
256+ addParticipant(c.Did.String())
257 }
258259 return participants
+110-113
appview/notify/db/db.go
···74 // no-op
75}
7677-func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
78- collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
0000000000000079 if err != nil {
80- log.Printf("failed to fetch collaborators: %v", err)
81 return
82 }
00000000000000008384- // build the recipients list
85- // - owner of the repo
86- // - collaborators in the repo
87- // - remove users already mentioned
88- recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
89- for _, c := range collaborators {
90- recipients.Insert(c.SubjectDid)
000000000000000000000000000000000000000000000000091 }
92- for _, m := range mentions {
093 recipients.Remove(m)
94 }
9596- actorDid := syntax.DID(issue.Did)
97- entityType := "issue"
98- entityId := issue.AtUri().String()
99- repoId := &issue.Repo.Id
100- issueId := &issue.Id
101- var pullId *int64
102-103 n.notifyEvent(
104- actorDid,
105 recipients,
106- models.NotificationTypeIssueCreated,
107 entityType,
108 entityId,
109 repoId,
···111 pullId,
112 )
113 n.notifyEvent(
114- actorDid,
115- sets.Collect(slices.Values(mentions)),
116 models.NotificationTypeUserMentioned,
117 entityType,
118 entityId,
···122 )
123}
124125-func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
126- issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt))
0000127 if err != nil {
128- log.Printf("NewIssueComment: failed to get issues: %v", err)
129 return
130 }
131- if len(issues) == 0 {
132- log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
133- return
134- }
135- issue := issues[0]
136137- // built the recipients list:
138- // - the owner of the repo
139- // - | if the comment is a reply -> everybody on that thread
140- // | if the comment is a top level -> just the issue owner
141- // - remove mentioned users from the recipients list
142 recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
143-144- if comment.IsReply() {
145- // if this comment is a reply, then notify everybody in that thread
146- parentAtUri := *comment.ReplyTo
147-148- // find the parent thread, and add all DIDs from here to the recipient list
149- for _, t := range issue.CommentList() {
150- if t.Self.AtUri().String() == parentAtUri {
151- for _, p := range t.Participants() {
152- recipients.Insert(p)
153- }
154- }
155- }
156- } else {
157- // not a reply, notify just the issue author
158- recipients.Insert(syntax.DID(issue.Did))
159 }
160-161 for _, m := range mentions {
162 recipients.Remove(m)
163 }
164165- actorDid := syntax.DID(comment.Did)
166 entityType := "issue"
167 entityId := issue.AtUri().String()
168 repoId := &issue.Repo.Id
···172 n.notifyEvent(
173 actorDid,
174 recipients,
175- models.NotificationTypeIssueCommented,
176 entityType,
177 entityId,
178 repoId,
···252 actorDid,
253 recipients,
254 eventType,
255- entityType,
256- entityId,
257- repoId,
258- issueId,
259- pullId,
260- )
261-}
262-263-func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) {
264- pull, err := db.GetPull(n.db,
265- syntax.ATURI(comment.RepoAt),
266- comment.PullId,
267- )
268- if err != nil {
269- log.Printf("NewPullComment: failed to get pulls: %v", err)
270- return
271- }
272-273- repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt))
274- if err != nil {
275- log.Printf("NewPullComment: failed to get repos: %v", err)
276- return
277- }
278-279- // build up the recipients list:
280- // - repo owner
281- // - all pull participants
282- // - remove those already mentioned
283- recipients := sets.Singleton(syntax.DID(repo.Did))
284- for _, p := range pull.Participants() {
285- recipients.Insert(syntax.DID(p))
286- }
287- for _, m := range mentions {
288- recipients.Remove(m)
289- }
290-291- actorDid := syntax.DID(comment.OwnerDid)
292- eventType := models.NotificationTypePullCommented
293- entityType := "pull"
294- entityId := pull.AtUri().String()
295- repoId := &repo.Id
296- var issueId *int64
297- p := int64(pull.ID)
298- pullId := &p
299-300- n.notifyEvent(
301- actorDid,
302- recipients,
303- eventType,
304- entityType,
305- entityId,
306- repoId,
307- issueId,
308- pullId,
309- )
310- n.notifyEvent(
311- actorDid,
312- sets.Collect(slices.Values(mentions)),
313- models.NotificationTypeUserMentioned,
314 entityType,
315 entityId,
316 repoId,
···74 // no-op
75}
7677+func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {
78+ var (
79+ // built the recipients list:
80+ // - the owner of the repo
81+ // - | if the comment is a reply -> everybody on that thread
82+ // | if the comment is a top level -> just the issue owner
83+ // - remove mentioned users from the recipients list
84+ recipients = sets.New[syntax.DID]()
85+ entityType string
86+ entityId string
87+ repoId *int64
88+ issueId *int64
89+ pullId *int64
90+ )
91+92+ subjectDid, err := comment.Subject.Authority().AsDID()
93 if err != nil {
94+ log.Printf("NewComment: expected did based at-uri for comment.subject")
95 return
96 }
97+ switch comment.Subject.Collection() {
98+ case tangled.RepoIssueNSID:
99+ issues, err := db.GetIssues(
100+ n.db,
101+ orm.FilterEq("did", subjectDid),
102+ orm.FilterEq("rkey", comment.Subject.RecordKey()),
103+ )
104+ if err != nil {
105+ log.Printf("NewComment: failed to get issues: %v", err)
106+ return
107+ }
108+ if len(issues) == 0 {
109+ log.Printf("NewComment: no issue found for %s", comment.Subject)
110+ return
111+ }
112+ issue := issues[0]
113114+ recipients.Insert(syntax.DID(issue.Repo.Did))
115+ if comment.IsReply() {
116+ // if this comment is a reply, then notify everybody in that thread
117+ parentAtUri := *comment.ReplyTo
118+119+ // find the parent thread, and add all DIDs from here to the recipient list
120+ for _, t := range issue.CommentList() {
121+ if t.Self.AtUri() == parentAtUri {
122+ for _, p := range t.Participants() {
123+ recipients.Insert(p)
124+ }
125+ }
126+ }
127+ } else {
128+ // not a reply, notify just the issue author
129+ recipients.Insert(syntax.DID(issue.Did))
130+ }
131+132+ entityType = "issue"
133+ entityId = issue.AtUri().String()
134+ repoId = &issue.Repo.Id
135+ issueId = &issue.Id
136+ case tangled.RepoPullNSID:
137+ pulls, err := db.GetPulls(
138+ n.db,
139+ orm.FilterEq("owner_did", subjectDid),
140+ orm.FilterEq("rkey", comment.Subject.RecordKey()),
141+ )
142+ if err != nil {
143+ log.Printf("NewComment: failed to get pulls: %v", err)
144+ return
145+ }
146+ if len(pulls) == 0 {
147+ log.Printf("NewComment: no pull found for %s", comment.Subject)
148+ return
149+ }
150+ pull := pulls[0]
151+152+ pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt))
153+ if err != nil {
154+ log.Printf("NewComment: failed to get repos: %v", err)
155+ return
156+ }
157+158+ recipients.Insert(syntax.DID(pull.Repo.Did))
159+ for _, p := range pull.Participants() {
160+ recipients.Insert(syntax.DID(p))
161+ }
162+163+ entityType = "pull"
164+ entityId = pull.AtUri().String()
165+ repoId = &pull.Repo.Id
166+ p := int64(pull.ID)
167+ pullId = &p
168+ default:
169+ return // no-op
170 }
171+172+ for _, m := range comment.Mentions {
173 recipients.Remove(m)
174 }
1750000000176 n.notifyEvent(
177+ comment.Did,
178 recipients,
179+ models.NotificationTypeIssueCommented,
180 entityType,
181 entityId,
182 repoId,
···184 pullId,
185 )
186 n.notifyEvent(
187+ comment.Did,
188+ sets.Collect(slices.Values(comment.Mentions)),
189 models.NotificationTypeUserMentioned,
190 entityType,
191 entityId,
···195 )
196}
197198+func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {
199+ // no-op
200+}
201+202+func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {
203+ collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt()))
204 if err != nil {
205+ log.Printf("failed to fetch collaborators: %v", err)
206 return
207 }
00000208209+ // build the recipients list
210+ // - owner of the repo
211+ // - collaborators in the repo
212+ // - remove users already mentioned
0213 recipients := sets.Singleton(syntax.DID(issue.Repo.Did))
214+ for _, c := range collaborators {
215+ recipients.Insert(c.SubjectDid)
00000000000000216 }
0217 for _, m := range mentions {
218 recipients.Remove(m)
219 }
220221+ actorDid := syntax.DID(issue.Did)
222 entityType := "issue"
223 entityId := issue.AtUri().String()
224 repoId := &issue.Repo.Id
···228 n.notifyEvent(
229 actorDid,
230 recipients,
231+ models.NotificationTypeIssueCreated,
232 entityType,
233 entityId,
234 repoId,
···308 actorDid,
309 recipients,
310 eventType,
00000000000000000000000000000000000000000000000000000000000311 entityType,
312 entityId,
313 repoId,
···1+# how to setup local appview dev environment
2+3+Appview requires several microservices from knot and spindle to entire atproto infra. This test environment is implemented under nixos vm.
4+5+1. copy `contrib/example.env` to `.env`, fill it and source it
6+2. run vm
7+ ```bash
8+ nix run --impure .#vm
9+ ```
10+3. trust the generated cert from host machine
11+ ```bash
12+ # for macos
13+ sudo security add-trusted-cert -d -r trustRoot \
14+ -k /Library/Keychains/System.keychain \
15+ ./nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/root.crt
16+ ```
17+4. create test accounts with valid emails (use [`create-test-account.sh`](./scripts/create-test-account.sh))
18+5. create default labels (use [`setup-const-records`](./scripts/setup-const-records.sh))
19+6. restart vm with correct owner-did
20+21+for git-https, you should change your local git config:
22+```
23+[http "https://knot.tngl.boltless.dev"]
24+ sslCAPath = /Users/boltless/repo/tangled/nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/
25+```
···23 nixpkgs.lib.nixosSystem {
24 inherit system;
25 modules = [
00026 self.nixosModules.knot
27 self.nixosModules.spindle
28 ({
···39 diskSize = 10 * 1024;
40 cores = 2;
41 forwardPorts = [
0000000000000000042 # ssh
43 {
44 from = "host";
···63 # as SQLite is incompatible with them. So instead we
64 # mount the shared directories to a different location
65 # and copy the contents around on service start/stop.
000066 knotData = {
67 source = "$TANGLED_VM_DATA_DIR/knot";
68 target = "/mnt/knot-data";
···79 };
80 # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall
81 networking.firewall.enable = false;
00000082 time.timeZone = "Europe/London";
083 services.getty.autologinUser = "root";
84 environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
00085 services.tangled.knot = {
86 enable = true;
87 motd = "Welcome to the development knot!\n";
···108 provider = "sqlite";
109 };
110 };
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111 };
112 users = {
113 # So we don't have to deal with permission clashing between