Monorepo for Tangled tangled.org

appview/repo: allow customizing atom feed

using the include query param, the user can now select portions of the
repo feed to listen to

Signed-off-by: oppiliappan <me@oppi.li>

authored by oppi.li and committed by tangled.org cfbb567c 3e74eb7e

+184 -25
+184 -25
appview/repo/feed.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "fmt" 6 7 "log" 7 8 "net/http" 8 9 "slices" 10 + "strings" 9 11 "time" 10 12 13 + "tangled.org/core/api/tangled" 11 14 "tangled.org/core/appview/db" 12 15 "tangled.org/core/appview/models" 13 16 "tangled.org/core/appview/pagination" 14 17 "tangled.org/core/orm" 18 + "tangled.org/core/types" 15 19 16 20 "github.com/bluesky-social/indigo/atproto/identity" 17 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 23 "github.com/gorilla/feeds" 19 24 ) 20 25 21 - func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string) (*feeds.Feed, error) { 22 - feedPagePerType := pagination.Page{Limit: 100} 26 + // which types of items to include in the feed. 27 + type FeedOpts struct { 28 + IncludeIssues bool 29 + IncludePulls bool 30 + IncludeCommits bool 31 + IncludeTags bool 32 + } 33 + 34 + func parseFeedOpts(r *http.Request) FeedOpts { 35 + includeParam := r.URL.Query().Get("include") 23 36 24 - pulls, err := db.GetPullsPaginated(rp.db, feedPagePerType, orm.FilterEq("repo_at", repo.RepoAt())) 25 - if err != nil { 26 - return nil, err 37 + // default: include everything 38 + if includeParam == "" { 39 + return FeedOpts{ 40 + IncludeIssues: true, 41 + IncludePulls: true, 42 + IncludeCommits: true, 43 + IncludeTags: true, 44 + } 27 45 } 28 46 29 - issues, err := db.GetIssuesPaginated( 30 - rp.db, 31 - feedPagePerType, 32 - orm.FilterEq("repo_at", repo.RepoAt()), 33 - ) 34 - if err != nil { 35 - return nil, err 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 + } 36 61 } 37 62 63 + return opts 64 + } 65 + 66 + func (rp *Repo) getRepoFeed(ctx context.Context, repo *models.Repo, ownerSlashRepo string, opts FeedOpts) (*feeds.Feed, error) { 67 + feedPagePerType := pagination.Page{Limit: 100} 68 + 38 69 feed := &feeds.Feed{ 39 70 Title: fmt.Sprintf("activity feed for @%s", ownerSlashRepo), 40 71 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.BaseUrl(), ownerSlashRepo), Type: "text/html", Rel: "alternate"}, ··· 42 73 Updated: time.UnixMilli(0), 43 74 } 44 75 45 - for _, pull := range pulls { 46 - items, err := rp.createPullItems(ctx, pull, repo, ownerSlashRepo) 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())) 47 79 if err != nil { 48 80 return nil, err 49 81 } 50 - feed.Items = append(feed.Items, items...) 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 + } 51 90 } 52 91 53 - for _, issue := range issues { 54 - item, err := rp.createIssueItem(ctx, issue, repo, ownerSlashRepo) 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 + ) 55 99 if err != nil { 56 100 return nil, err 57 101 } 58 - feed.Items = append(feed.Items, item) 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 + } 59 132 } 60 133 61 134 slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { ··· 65 138 return 1 66 139 }) 67 140 141 + if len(feed.Items) > 100 { 142 + feed.Items = feed.Items[:100] 143 + } 144 + 68 145 if len(feed.Items) > 0 { 69 146 feed.Updated = feed.Items[0].Created 70 147 } ··· 72 149 return feed, nil 73 150 } 74 151 75 - func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, repo *models.Repo, ownerSlashRepo string) ([]*feeds.Item, error) { 152 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, ownerSlashRepo string) ([]*feeds.Item, error) { 76 153 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 77 154 if err != nil { 78 155 return nil, err ··· 88 165 Description: description, 89 166 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId)}, 90 167 Created: pull.Created, 91 - Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 168 + Author: &feeds.Author{Name: fmt.Sprintf("%s", owner.Handle)}, 92 169 } 93 170 items = append(items, mainItem) 94 171 ··· 99 176 100 177 roundItem := &feeds.Item{ 101 178 Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 102 - Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in @%s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 179 + Description: fmt.Sprintf("%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, ownerSlashRepo), 103 180 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.BaseUrl(), ownerSlashRepo, pull.PullId, round.RoundNumber)}, 104 181 Created: round.Created, 105 182 Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, ··· 110 187 return items, nil 111 188 } 112 189 113 - func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, repo *models.Repo, ownerSlashRepo string) (*feeds.Item, error) { 190 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, ownerSlashRepo string) (*feeds.Item, error) { 114 191 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 115 192 if err != nil { 116 193 return nil, err ··· 123 200 124 201 return &feeds.Item{ 125 202 Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 126 - Description: fmt.Sprintf("@%s %s issue #%d in @%s", owner.Handle, state, issue.IssueId, ownerSlashRepo), 203 + Description: fmt.Sprintf("%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, ownerSlashRepo), 127 204 Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.BaseUrl(), ownerSlashRepo, issue.IssueId)}, 128 205 Created: issue.Created, 129 - Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 206 + Author: &feeds.Author{Name: owner.Handle.String()}, 130 207 }, nil 131 208 } 132 209 210 + func (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 + 249 + func (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 + 133 291 func (rp *Repo) getPullState(pull *models.Pull) string { 134 292 if pull.State == models.PullOpen { 135 293 return "opened" ··· 160 318 } 161 319 ownerSlashRepo := repoOwnerId.Handle.String() + "/" + f.Name 162 320 163 - feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo) 321 + opts := parseFeedOpts(r) 322 + feed, err := rp.getRepoFeed(r.Context(), f, ownerSlashRepo, opts) 164 323 if err != nil { 165 324 log.Println("failed to get repo feed:", err) 166 325 rp.pages.Error500(w)