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.6 kB 337 lines
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}