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(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}