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