Monorepo for Tangled
tangled.org
1package repo
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "slices"
9 "strings"
10 "time"
11
12 "tangled.org/core/api/tangled"
13 "tangled.org/core/appview/db"
14 "tangled.org/core/appview/models"
15 "tangled.org/core/appview/pagination"
16 "tangled.org/core/orm"
17 "tangled.org/core/types"
18
19 "github.com/bluesky-social/indigo/atproto/identity"
20 "github.com/bluesky-social/indigo/atproto/syntax"
21 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
22 "github.com/gorilla/feeds"
23)
24
25// which types of items to include in the feed.
26type FeedOpts struct {
27 IncludeIssues bool
28 IncludePulls bool
29 IncludeCommits bool
30 IncludeTags bool
31}
32
33func parseFeedOpts(r *http.Request) FeedOpts {
34 includeParam := r.URL.Query().Get("include")
35
36 // default: include everything
37 if includeParam == "" {
38 return FeedOpts{
39 IncludeIssues: true,
40 IncludePulls: true,
41 IncludeCommits: true,
42 IncludeTags: true,
43 }
44 }
45
46 // parse comma-separated list
47 opts := FeedOpts{}
48 types := strings.SplitSeq(includeParam, ",")
49 for t := range types {
50 switch strings.TrimSpace(strings.ToLower(t)) {
51 case "issues":
52 opts.IncludeIssues = true
53 case "pulls", "prs":
54 opts.IncludePulls = true
55 case "commits":
56 opts.IncludeCommits = true
57 case "tags":
58 opts.IncludeTags = true
59 }
60 }
61
62 return opts
63}
64
65func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string, opts FeedOpts) (*feeds.Feed, error) {
66 feedPagePerType := pagination.Page{Limit: 100}
67
68 feed := &feeds.Feed{
69 Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo),
70 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.BaseUrl(), ownerSlashRepo), Type: "text/html", Rel: "alternate"},
71 Items: make([]*feeds.Item, 0),
72 Updated: time.UnixMilli(0),
73 }
74
75 // fetch and add pull requests if requested
76 if opts.IncludePulls {
77 pulls, err := db.GetPullsPaginated(rp.db, feedPagePerType, orm.FilterEq("repo_did", repo.RepoDid))
78 if err != nil {
79 return nil, err
80 }
81
82 for _, pull := range pulls {
83 items, err := rp.createPullItems(ctx, pull, ownerSlashRepo)
84 if err != nil {
85 return nil, err
86 }
87 feed.Items = append(feed.Items, items...)
88 }
89 }
90
91 // fetch and add issues if requested
92 if opts.IncludeIssues {
93 issues, err := db.GetIssuesPaginated(
94 rp.db,
95 feedPagePerType,
96 orm.FilterEq("repo_did", repo.RepoDid),
97 )
98 if err != nil {
99 return nil, err
100 }
101
102 for _, issue := range issues {
103 item, err := rp.createIssueItem(ctx, issue, ownerSlashRepo)
104 if err != nil {
105 return nil, err
106 }
107 feed.Items = append(feed.Items, item)
108 }
109 }
110
111 // fetch and add commits if requested
112 if opts.IncludeCommits {
113 commitItems, err := rp.createCommitItems(ctx, repo, ownerSlashRepo)
114 if err != nil {
115 // Soft failure: log error and continue with partial feed
116 rp.logger.Error("failed to fetch commits for feed", "err", err)
117 } else {
118 feed.Items = append(feed.Items, commitItems...)
119 }
120 }
121
122 // fetch and add tags if requested
123 if opts.IncludeTags {
124 tagItems, err := rp.createTagItems(ctx, repo, ownerSlashRepo)
125 if err != nil {
126 // Soft failure: log error and continue with partial feed
127 rp.logger.Error("failed to fetch tags for feed", "err", err)
128 } else {
129 feed.Items = append(feed.Items, tagItems...)
130 }
131 }
132
133 slices.SortFunc(feed.Items, func(a, b *feeds.Item) int {
134 if a.Created.After(b.Created) {
135 return -1
136 }
137 return 1
138 })
139
140 if len(feed.Items) > 100 {
141 feed.Items = feed.Items[:100]
142 }
143
144 if len(feed.Items) > 0 {
145 feed.Updated = feed.Items[0].Created
146 }
147
148 return feed, nil
149}
150
151func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, ownerSlashRepo string) ([]*feeds.Item, error) {
152 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
153 if err != nil {
154 return nil, err
155 }
156
157 var items []*feeds.Item
158
159 state := rp.getPullState(pull)
160 description := rp.buildPullDescription(owner.Handle, state, pull, ownerSlashRepo)
161
162 mainItem := &feeds.Item{
163 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
164 Description: description,
165 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId)},
166 Created: pull.Created,
167 Author: &feeds.Author{Name: fmt.Sprintf("%s", owner.Handle)},
168 }
169 items = append(items, mainItem)
170
171 for _, round := range pull.Submissions {
172 if round == nil || round.RoundNumber == 0 {
173 continue
174 }
175
176 roundItem := &feeds.Item{
177 Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber),
178 Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo),
179 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.RoundNumber)},
180 Created: round.Created,
181 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
182 }
183 items = append(items, roundItem)
184 }
185
186 return items, nil
187}
188
189func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, ownerSlashRepo string) (*feeds.Item, error) {
190 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
191 if err != nil {
192 return nil, err
193 }
194
195 state := "closed"
196 if issue.Open {
197 state = "opened"
198 }
199
200 return &feeds.Item{
201 Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
202 Description: fmt.Sprintf("%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, ownerSlashRepo),
203 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.BaseUrl(), ownerSlashRepo, issue.IssueId)},
204 Created: issue.Created,
205 Author: &feeds.Author{Name: owner.Handle.String()},
206 }, nil
207}
208
209func (rp *Repo) createCommitItems(ctx context.Context, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) {
210 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url}
211
212 xrpcBytes, err := tangled.GitTempListCommits(ctx, xrpcc, "", 100, "", repo.RepoDid)
213 if err != nil {
214 return nil, fmt.Errorf("failed to call XRPC repo.log: %w", err)
215 }
216
217 var xrpcResp types.RepoLogResponse
218 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
219 return nil, fmt.Errorf("failed to decode XRPC response: %w", err)
220 }
221
222 var items []*feeds.Item
223 for _, commit := range xrpcResp.Commits {
224 messageLines := strings.SplitN(commit.Message, "\n", 2)
225 firstLine := messageLines[0]
226 if firstLine == "" {
227 firstLine = "(no message)"
228 }
229
230 shortHash := commit.Hash.String()
231 if len(shortHash) > 7 {
232 shortHash = shortHash[:7]
233 }
234
235 item := &feeds.Item{
236 Title: fmt.Sprintf("[Commit %s] %s", shortHash, firstLine),
237 Description: commit.Message,
238 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/commit/%s", rp.config.Core.BaseUrl(), ownerSlashRepo, commit.Hash.String())},
239 Created: commit.Author.When,
240 Author: &feeds.Author{Name: commit.Author.Name, Email: commit.Author.Email},
241 }
242 items = append(items, item)
243 }
244
245 return items, nil
246}
247
248func (rp *Repo) createTagItems(ctx context.Context, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) {
249 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url}
250
251 tagBytes, err := tangled.GitTempListTags(ctx, xrpcc, "", 100, repo.RepoDid)
252 if err != nil {
253 return nil, fmt.Errorf("failed to call XRPC repo.tags: %w", err)
254 }
255
256 var tagResp types.RepoTagsResponse
257 if err := json.Unmarshal(tagBytes, &tagResp); err != nil {
258 return nil, fmt.Errorf("failed to decode XRPC response: %w", err)
259 }
260
261 var items []*feeds.Item
262 for _, tag := range tagResp.Tags {
263 var description string
264
265 // only handle annotated tags for now
266 if tag.Tag != nil {
267 if tag.Tag.Message != "" {
268 description = fmt.Sprintf("Tag %s created by %s:\n\n%s", tag.Name, tag.Tag.Tagger.Name, tag.Tag.Message)
269 } else {
270 description = fmt.Sprintf("Tag %s created by %s", tag.Name, tag.Tag.Tagger.Name)
271 }
272
273 item := &feeds.Item{
274 Title: fmt.Sprintf("[Tag] %s", tag.Name),
275 Description: description,
276 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/tags/%s", rp.config.Core.BaseUrl(), ownerSlashRepo, tag.Name)},
277 Created: tag.Tag.Tagger.When,
278 Author: &feeds.Author{
279 Name: tag.Tag.Tagger.Name,
280 Email: tag.Tag.Tagger.Email,
281 },
282 }
283 items = append(items, item)
284 }
285 }
286
287 return items, nil
288}
289
290func (rp *Repo) getPullState(pull *models.Pull) string {
291 if pull.State == models.PullOpen {
292 return "opened"
293 }
294 return pull.State.String()
295}
296
297func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string {
298 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
299
300 if pull.State == models.PullMerged {
301 return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
302 }
303
304 return fmt.Sprintf("%s in %s", base, repoName)
305}
306
307func (rp *Repo) AtomFeed(w http.ResponseWriter, r *http.Request) {
308 f, err := rp.repoResolver.Resolve(r)
309 if err != nil {
310 rp.logger.Error("failed to fully resolve repo", "err", err)
311 return
312 }
313 repoOwnerId, ok := r.Context().Value("resolvedId").(identity.Identity)
314 if !ok || repoOwnerId.Handle.IsInvalidHandle() {
315 rp.logger.Error("failed to get resolved repo owner id")
316 return
317 }
318 ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Slug()
319
320 opts := parseFeedOpts(r)
321 feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo, opts)
322 if err != nil {
323 rp.logger.Error("failed to get repo feed", "err", err)
324 rp.pages.Error500(w)
325 return
326 }
327
328 atom, err := feed.ToAtom()
329 if err != nil {
330 rp.pages.Error500(w)
331 return
332 }
333
334 w.Header().Set("content-type", "application/atom+xml")
335 w.Write([]byte(atom))
336}