···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
···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+}
+28-2
appview/models/pull.go
···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
···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
+113-110
appview/notify/db/db.go
···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)
0000206 return
207 }
0208209- // 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,
···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,
···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>
···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>
···375KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
376```
377378-If you run a Linux distribution that uses systemd, you can use the provided
379-service file to run the server. Copy
380-[`knotserver.service`](/systemd/knotserver.service)
381to `/etc/systemd/system/`. Then, run:
382383```
···692 NODE_ENV: "production"
693 MY_ENV_VAR: "MY_ENV_VALUE"
694```
000000000000000000000000000000000695696### Steps
697
···375KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
376```
377378+If you run a Linux distribution that uses systemd, you can
379+use the provided service file to run the server. Copy
380+[`knotserver.service`](https://tangled.org/tangled.org/core/blob/master/systemd/knotserver.service)
381to `/etc/systemd/system/`. Then, run:
382383```
···692 NODE_ENV: "production"
693 MY_ENV_VAR: "MY_ENV_VALUE"
694```
695+696+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
728729### Steps
730