This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / repo / feed.go
9.5 kB 335 lines
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}