Monorepo for Tangled tangled.org
4

Configure Feed

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

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