···1+package models
2+3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+8+ "github.com/bluesky-social/indigo/atproto/syntax"
9+ "tangled.org/core/api/tangled"
10+)
11+12+type Comment struct {
13+ Id int64
14+ Did syntax.DID
15+ Rkey string
16+ Subject syntax.ATURI
17+ ReplyTo *syntax.ATURI
18+ Body string
19+ Created time.Time
20+ Edited *time.Time
21+ Deleted *time.Time
22+ Mentions []syntax.DID
23+ References []syntax.ATURI
24+ PullSubmissionId *int
25+}
26+27+func (c *Comment) AtUri() syntax.ATURI {
28+ return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, tangled.CommentNSID, c.Rkey))
29+}
30+31+func (c *Comment) AsRecord() tangled.Comment {
32+ mentions := make([]string, len(c.Mentions))
33+ for i, did := range c.Mentions {
34+ mentions[i] = string(did)
35+ }
36+ references := make([]string, len(c.References))
37+ for i, uri := range c.References {
38+ references[i] = string(uri)
39+ }
40+ var replyTo *string
41+ if c.ReplyTo != nil {
42+ replyToStr := c.ReplyTo.String()
43+ replyTo = &replyToStr
44+ }
45+ return tangled.Comment{
46+ Subject: c.Subject.String(),
47+ Body: c.Body,
48+ CreatedAt: c.Created.Format(time.RFC3339),
49+ ReplyTo: replyTo,
50+ Mentions: mentions,
51+ References: references,
52+ }
53+}
54+55+func (c *Comment) IsTopLevel() bool {
56+ return c.ReplyTo == nil
57+}
58+59+func (c *Comment) IsReply() bool {
60+ return c.ReplyTo != nil
61+}
62+63+func (c *Comment) Validate() error {
64+ // TODO: sanitize the body and then trim space
65+ if sb := strings.TrimSpace(c.Body); sb == "" {
66+ return fmt.Errorf("body is empty after HTML sanitization")
67+ }
68+69+ // if it's for PR, PullSubmissionId should not be nil
70+ if c.Subject.Collection().String() == tangled.RepoPullNSID {
71+ if c.PullSubmissionId == nil {
72+ return fmt.Errorf("PullSubmissionId should not be nil")
73+ }
74+ }
75+ return nil
76+}
77+78+func CommentFromRecord(did, rkey string, record tangled.Comment) (*Comment, error) {
79+ created, err := time.Parse(time.RFC3339, record.CreatedAt)
80+ if err != nil {
81+ created = time.Now()
82+ }
83+84+ ownerDid := did
85+86+ if _, err = syntax.ParseATURI(record.Subject); err != nil {
87+ return nil, err
88+ }
89+90+ i := record
91+ mentions := make([]syntax.DID, len(record.Mentions))
92+ for i, did := range record.Mentions {
93+ mentions[i] = syntax.DID(did)
94+ }
95+ references := make([]syntax.ATURI, len(record.References))
96+ for i, uri := range i.References {
97+ references[i] = syntax.ATURI(uri)
98+ }
99+ var replyTo *syntax.ATURI
100+ if record.ReplyTo != nil {
101+ replyToAtUri := syntax.ATURI(*record.ReplyTo)
102+ replyTo = &replyToAtUri
103+ }
104+105+ comment := Comment{
106+ Did: syntax.DID(ownerDid),
107+ Rkey: rkey,
108+ Body: record.Body,
109+ Subject: syntax.ATURI(record.Subject),
110+ ReplyTo: replyTo,
111+ Created: created,
112+ Mentions: mentions,
113+ References: references,
114+ }
115+116+ return &comment, nil
117+}
+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,
···30 <div class="mx-6">
31 These services may not be fully accessible until upgraded.
32 <a class="underline text-red-800 dark:text-red-200"
33- href="https://docs.tangled.org/migrating-knots-and-spindles.html">
34 Click to read the upgrade guide</a>.
35 </div>
36 </details>
···30 <div class="mx-6">
31 These services may not be fully accessible until upgraded.
32 <a class="underline text-red-800 dark:text-red-200"
33+ href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles">
34 Click to read the upgrade guide</a>.
35 </div>
36 </details>
···693 MY_ENV_VAR: "MY_ENV_VALUE"
694```
695696-By default, the following environment variables set:
697-698-- `CI` - Always set to `true` to indicate a CI environment
699-- `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline
700-- `TANGLED_REPO_KNOT` - The repository's knot hostname
701-- `TANGLED_REPO_DID` - The DID of the repository owner
702-- `TANGLED_REPO_NAME` - The name of the repository
703-- `TANGLED_REPO_DEFAULT_BRANCH` - The default branch of the
704- repository
705-- `TANGLED_REPO_URL` - The full URL to the repository
706-707-These variables are only available when the pipeline is
708-triggered by a push:
709-710-- `TANGLED_REF` - The full git reference (e.g.,
711- `refs/heads/main` or `refs/tags/v1.0.0`)
712-- `TANGLED_REF_NAME` - The short name of the reference
713- (e.g., `main` or `v1.0.0`)
714-- `TANGLED_REF_TYPE` - The type of reference, either
715- `branch` or `tag`
716-- `TANGLED_SHA` - The commit SHA that triggered the pipeline
717-- `TANGLED_COMMIT_SHA` - Alias for `TANGLED_SHA`
718-719-These variables are only available when the pipeline is
720-triggered by a pull request:
721-722-- `TANGLED_PR_SOURCE_BRANCH` - The source branch of the pull
723- request
724-- `TANGLED_PR_TARGET_BRANCH` - The target branch of the pull
725- request
726-- `TANGLED_PR_SOURCE_SHA` - The commit SHA of the source
727- branch
728-729### Steps
730731The `steps` field allows you to define what steps should run
···693 MY_ENV_VAR: "MY_ENV_VALUE"
694```
695000000000000000000000000000000000696### Steps
697698The `steps` field allows you to define what steps should run