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(ctx, 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.Directory().LookupDID(ctx, pull.OwnerDid)
152 if err != nil {
153 return nil, err
154 }
155
156 var items []*feeds.Item
157
158 description := rp.buildPullDescription(owner.Handle, pull, ownerSlashRepo)
159
160 mainItem := &feeds.Item{
161 Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title),
162 Description: description,
163 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId)},
164 Created: pull.Created,
165 Author: &feeds.Author{Name: fmt.Sprintf("%s", owner.Handle)},
166 }
167 items = append(items, mainItem)
168
169 for _, round := range pull.Versions {
170 if round.ID == 0 {
171 continue
172 }
173
174 roundItem := &feeds.Item{
175 Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.ID),
176 Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.ID, pull.PullId, ownerSlashRepo),
177 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.ID)},
178 Created: round.Created,
179 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)},
180 }
181 items = append(items, roundItem)
182 }
183
184 return items, nil
185}
186
187func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, ownerSlashRepo string) (*feeds.Item, error) {
188 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
189 if err != nil {
190 return nil, err
191 }
192
193 state := "closed"
194 if issue.Open {
195 state = "opened"
196 }
197
198 return &feeds.Item{
199 Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title),
200 Description: fmt.Sprintf("%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, ownerSlashRepo),
201 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.BaseUrl(), ownerSlashRepo, issue.IssueId)},
202 Created: issue.Created,
203 Author: &feeds.Author{Name: owner.Handle.String()},
204 }, nil
205}
206
207func (rp *Repo) createCommitItems(ctx context.Context, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) {
208 xrpcc := rp.knotMirrorXRPCClient()
209
210 xrpcBytes, err := tangled.GitTempListCommits(ctx, xrpcc, "", 100, "", repo.RepoDid)
211 if err != nil {
212 return nil, fmt.Errorf("failed to call XRPC repo.log: %w", err)
213 }
214
215 var xrpcResp types.RepoLogResponse
216 if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil {
217 return nil, fmt.Errorf("failed to decode XRPC response: %w", err)
218 }
219
220 var items []*feeds.Item
221 for _, commit := range xrpcResp.Commits {
222 messageLines := strings.SplitN(commit.Message, "\n", 2)
223 firstLine := messageLines[0]
224 if firstLine == "" {
225 firstLine = "(no message)"
226 }
227
228 shortHash := commit.Hash.String()
229 if len(shortHash) > 7 {
230 shortHash = shortHash[:7]
231 }
232
233 item := &feeds.Item{
234 Title: fmt.Sprintf("[Commit %s] %s", shortHash, firstLine),
235 Description: commit.Message,
236 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/commit/%s", rp.config.Core.BaseUrl(), ownerSlashRepo, commit.Hash.String())},
237 Created: commit.Author.When,
238 Author: &feeds.Author{Name: commit.Author.Name, Email: commit.Author.Email},
239 }
240 items = append(items, item)
241 }
242
243 return items, nil
244}
245
246func (rp *Repo) createTagItems(ctx context.Context, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) {
247 xrpcc := rp.knotMirrorXRPCClient()
248
249 tagBytes, err := tangled.GitTempListTags(ctx, xrpcc, "", 100, repo.RepoDid)
250 if err != nil {
251 return nil, fmt.Errorf("failed to call XRPC repo.tags: %w", err)
252 }
253
254 var tagResp types.RepoTagsResponse
255 if err := json.Unmarshal(tagBytes, &tagResp); err != nil {
256 return nil, fmt.Errorf("failed to decode XRPC response: %w", err)
257 }
258
259 var items []*feeds.Item
260 for _, tag := range tagResp.Tags {
261 var description string
262
263 // only handle annotated tags for now
264 if tag.Tag != nil {
265 if tag.Tag.Message != "" {
266 description = fmt.Sprintf("Tag %s created by %s:\n\n%s", tag.Name, tag.Tag.Tagger.Name, tag.Tag.Message)
267 } else {
268 description = fmt.Sprintf("Tag %s created by %s", tag.Name, tag.Tag.Tagger.Name)
269 }
270
271 item := &feeds.Item{
272 Title: fmt.Sprintf("[Tag] %s", tag.Name),
273 Description: description,
274 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/tags/%s", rp.config.Core.BaseUrl(), ownerSlashRepo, tag.Name)},
275 Created: tag.Tag.Tagger.When,
276 Author: &feeds.Author{
277 Name: tag.Tag.Tagger.Name,
278 Email: tag.Tag.Tagger.Email,
279 },
280 }
281 items = append(items, item)
282 }
283 }
284
285 return items, nil
286}
287
288func (rp *Repo) getPullState(pull *models.Pull) string {
289 if pull.State == models.PullOpen {
290 return "opened"
291 }
292 return pull.State.String()
293}
294
295func (rp *Repo) buildPullDescription(handle syntax.Handle, pull *models.Pull, repoName string) string {
296 state := rp.getPullState(pull)
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.LatestVersionNumber(), 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}