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