steid

@jamesgill /

Rust18.1 KBCode·Blame·History·Raw
1//! What the three Atom feeds read.
2//!
3//! Feeds are read by software that nobody is watching — a reader polls every half hour
4//! whether or not anyone opens it — so these use cases are deliberately the cheapest
5//! reads in Steid. The commit feed is one `git` process; the issue and activity feeds
6//! are none at all.
7//!
8//! Visibility is not re-decided here. Each one goes through the same resolution its
9//! pages do, so a private repository's feed answers `None` for a stranger exactly as
10//! its landing page does, and the activity feed lists only repositories the requester
11//! could have reached by clicking.
12
13use std::time::SystemTime;
14
15use crate::domain::{
16 Actor, CommitEntry, IssueNumber, IssueState, OrgName, PushEvent, RefName, RepoName,
17 repository::{
18 IssueRepository, MembershipRepository, OrgRepository, PushEventRepository, RepoRepository,
19 UserRepository,
20 },
21};
22
23use super::{
24 access::{Stores, is_org_member, visible_repo},
25 browse::resolve_revision,
26 error::Result,
27 issue::Handles,
28 port::GitQuery,
29};
30
31/// How many entries a feed carries.
32///
33/// The same fifty the log page shows, and for the same reason: a feed is a window on
34/// what changed recently, not an archive. A reader that has been away longer than fifty
35/// commits has the pages to catch up on.
36pub const FEED_LIMIT: usize = 50;
37
38/// An issue as a feed entry needs it — the body included, which a listing row omits.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct IssueEntry {
41 pub number: IssueNumber,
42 pub title: String,
43 /// Markdown, exactly as written. Rendering it is the web layer's job.
44 pub body: String,
45 pub state: IssueState,
46 /// The author's handle, or `None` if the account is gone — the same answer
47 /// [`IssueSummary`](super::IssueSummary) gives, for the same reason.
48 pub author: Option<OrgName>,
49 pub created_at: SystemTime,
50 pub updated_at: SystemTime,
51}
52
53/// One push, with the repository it landed in.
54///
55/// The repository's name rather than its id: the feed writes a URL out of it, and a
56/// reader of an activity feed is being told *where* something happened first of all.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ActivityEntry {
59 pub repo: RepoName,
60 pub event: PushEvent,
61 /// Who pushed, or `None` for a deleted account.
62 pub pusher: Option<OrgName>,
63}
64
65/// The most recent commits on a revision, with their message bodies.
66///
67/// `Ok(None)` on the same terms as [`repo_log`](super::repo_log): the repository is not
68/// there, or the reader may not see it, and the route must answer both identically.
69/// An empty repository feeds nothing rather than failing.
70///
71/// **One `git` process**, or two when the revision has to be resolved from `HEAD`.
72pub async fn commit_feed(
73 handle: &OrgName,
74 name: &RepoName,
75 rev: Option<&RefName>,
76 actor: &Actor,
77 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
78 queries: &impl GitQuery,
79) -> Result<Option<Vec<CommitEntry>>> {
80 if visible_repo(actor, handle, name, stores).await?.is_none() {
81 return Ok(None);
82 }
83
84 let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
85 return Ok(Some(Vec::new()));
86 };
87
88 Ok(Some(
89 queries.log_entries(handle, name, &rev, FEED_LIMIT).await?,
90 ))
91}
92
93/// The most recent issues on a repository, newest first, open and closed together.
94///
95/// Both states in one list because a feed is a record of what happened, and an issue
96/// being closed does not un-happen its opening. The two are fetched separately —
97/// [`IssueRepository::list_by_repo`] takes a state — and merged here, which is two
98/// queries rather than a port method that exists only for this.
99pub async fn issue_feed(
100 actor: &Actor,
101 handle: &OrgName,
102 name: &RepoName,
103 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
104 issues: &impl IssueRepository,
105 users: &impl UserRepository,
106) -> Result<Option<Vec<IssueEntry>>> {
107 let Some((_, repo)) = visible_repo(actor, handle, name, stores).await? else {
108 return Ok(None);
109 };
110
111 // The newest `FEED_LIMIT` of each state, because the merge below keeps at most that
112 // many overall — anything older than the 50th open issue cannot survive it.
113 let mut listed = issues
114 .list_by_repo(&repo.id, IssueState::Open, 0, FEED_LIMIT)
115 .await?;
116 listed.extend(
117 issues
118 .list_by_repo(&repo.id, IssueState::Closed, 0, FEED_LIMIT)
119 .await?,
120 );
121
122 // Newest first, ties broken by descending number — the order each state's listing
123 // already promises, reasserted because merging two of them loses it.
124 listed.sort_by(|a, b| {
125 b.created_at
126 .cmp(&a.created_at)
127 .then_with(|| b.number.get().cmp(&a.number.get()))
128 });
129 listed.truncate(FEED_LIMIT);
130
131 let mut handles = Handles::default();
132 let mut entries = Vec::with_capacity(listed.len());
133
134 for issue in listed {
135 entries.push(IssueEntry {
136 author: handles.of(&issue.author_id, stores.orgs, users).await?,
137 number: issue.number,
138 title: issue.title,
139 body: issue.body,
140 state: issue.state,
141 created_at: issue.created_at,
142 updated_at: issue.updated_at,
143 });
144 }
145
146 Ok(Some(entries))
147}
148
149/// One owner's recent pushes, across every repository the reader may see.
150///
151/// `Ok(None)` means no such handle, which is the profile page's 404. A handle whose
152/// repositories are all private answers with an empty list to a stranger — the same
153/// silence [`list_repos`](super::list_repos) gives, and for the same reason: a count
154/// would leak both the existence and the number of them.
155///
156/// **No `git` processes.** Everything an entry says comes out of the `push_events`
157/// table; the pages an entry links to do the reading when somebody follows the link.
158pub async fn activity_feed(
159 handle: &OrgName,
160 actor: &Actor,
161 stores: &Stores<'_, impl OrgRepository, impl MembershipRepository, impl RepoRepository>,
162 events: &impl PushEventRepository,
163 users: &impl UserRepository,
164) -> Result<Option<Vec<ActivityEntry>>> {
165 let Some(org) = stores.orgs.find_by_name(handle).await? else {
166 return Ok(None);
167 };
168
169 // Resolved once for the whole listing, exactly as `list_repos` does it: membership
170 // cannot change mid-read, and asking per repository would be a query per repository.
171 let is_member = is_org_member(&org, actor, stores.memberships).await?;
172
173 let visible: Vec<_> = stores
174 .repos
175 .list_by_org(&org.id)
176 .await?
177 .into_iter()
178 .filter(|repo| repo.visibility.is_public() || is_member)
179 .collect();
180
181 let ids: Vec<_> = visible.iter().map(|repo| repo.id.clone()).collect();
182 let recent = events.list_recent(&ids, FEED_LIMIT).await?;
183
184 let mut handles = Handles::default();
185 let mut entries = Vec::with_capacity(recent.len());
186
187 for event in recent {
188 // The repository is in `visible` by construction — `list_recent` was asked
189 // about exactly these — so a miss would be a storage inconsistency rather than
190 // a permission question, and skipping it is the safe reading either way.
191 let Some(repo) = visible.iter().find(|repo| repo.id == event.repo_id) else {
192 continue;
193 };
194
195 let pusher = match &event.pusher {
196 Some(id) => handles.of(id, stores.orgs, users).await?,
197 None => None,
198 };
199
200 entries.push(ActivityEntry {
201 repo: repo.name.clone(),
202 event,
203 pusher,
204 });
205 }
206
207 Ok(Some(entries))
208}
209
210#[cfg(test)]
211mod tests {
212 use std::time::Duration;
213
214 use super::*;
215 use crate::{
216 domain::{
217 CommitSummary, Email, Issue, Membership, MembershipId, ObjectId, OrgId, Organization,
218 PasswordHash, PushEventId, RepoId, Repository, Role, User, UserId, Visibility,
219 repository::{MembershipRepository, OrgRepository, RepoRepository, UserRepository},
220 },
221 infrastructure::{
222 git::InMemoryGitQuery,
223 repository::{
224 InMemoryIssueRepo, InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryPushEventRepo,
225 InMemoryRepoRepo, InMemoryUserRepo,
226 },
227 },
228 };
229
230 struct Fixture {
231 orgs: InMemoryOrgRepo,
232 memberships: InMemoryMembershipRepo,
233 repos: InMemoryRepoRepo,
234 issues: InMemoryIssueRepo,
235 events: InMemoryPushEventRepo,
236 users: InMemoryUserRepo,
237 owner: UserId,
238 org: Organization,
239 public: Repository,
240 private: Repository,
241 }
242
243 fn at(seconds: u64) -> SystemTime {
244 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
245 }
246
247 fn handle() -> OrgName {
248 OrgName::new("ada").expect("valid handle")
249 }
250
251 async fn fixture() -> Fixture {
252 let orgs = InMemoryOrgRepo::new();
253 let memberships = InMemoryMembershipRepo::new();
254 let repos = InMemoryRepoRepo::new();
255 let issues = InMemoryIssueRepo::new();
256 let events = InMemoryPushEventRepo::new();
257 let users = InMemoryUserRepo::new();
258
259 let org = Organization::new(OrgId::generate(), "ada", None).expect("valid org");
260 orgs.save(&org).await.expect("save org");
261
262 let owner = UserId::generate();
263 memberships
264 .save(&Membership::new(
265 MembershipId::generate(),
266 org.id.clone(),
267 owner.clone(),
268 Role::Owner,
269 ))
270 .await
271 .expect("save membership");
272 users
273 .save(&User::new(
274 owner.clone(),
275 Email::new("ada@example.com").expect("valid email"),
276 PasswordHash::from_trusted("$argon2id$test"),
277 org.id.clone(),
278 ))
279 .await
280 .expect("save user");
281
282 let public = Repository::new(
283 RepoId::generate(),
284 org.id.clone(),
285 "steid",
286 None,
287 Visibility::Public,
288 at(1_000),
289 )
290 .expect("valid repo");
291 repos.save(&public).await.expect("save repo");
292
293 let private = Repository::new(
294 RepoId::generate(),
295 org.id.clone(),
296 "secret",
297 None,
298 Visibility::Private,
299 at(1_000),
300 )
301 .expect("valid repo");
302 repos.save(&private).await.expect("save repo");
303
304 Fixture {
305 orgs,
306 memberships,
307 repos,
308 issues,
309 events,
310 users,
311 owner,
312 org,
313 public,
314 private,
315 }
316 }
317
318 async fn push(f: &Fixture, repo: &Repository, name: &str, seconds: u64) {
319 let event = PushEvent::new(
320 PushEventId::generate(),
321 repo.id.clone(),
322 RefName::from_trusted(format!("refs/heads/{name}")),
323 ObjectId::from_trusted("a".repeat(40)),
324 ObjectId::from_trusted("b".repeat(40)),
325 Some(f.owner.clone()),
326 at(seconds),
327 )
328 .expect("valid event");
329 f.events.save(&event).await.expect("save event");
330 }
331
332 async fn open_issue(f: &Fixture, number: u64, title: &str, seconds: u64) -> Issue {
333 let issue = Issue::new(
334 crate::domain::IssueId::generate(),
335 f.public.id.clone(),
336 IssueNumber::new(number).expect("valid number"),
337 title,
338 "a **body**",
339 f.owner.clone(),
340 at(seconds),
341 )
342 .expect("valid issue");
343 f.issues.save(&issue).await.expect("save issue");
344 issue
345 }
346
347 fn queries() -> InMemoryGitQuery {
348 InMemoryGitQuery::new()
349 .with_log(vec![CommitSummary {
350 id: ObjectId::from_trusted("c".repeat(40)),
351 summary: "first".to_owned(),
352 author_name: "Ada".to_owned(),
353 committed_at: at(2_000),
354 }])
355 .with_commit_body(&"c".repeat(40), "why it happened")
356 }
357
358 // --- commits ------------------------------------------------------------------
359
360 #[tokio::test]
361 async fn a_public_repository_feeds_its_commits_to_a_stranger() {
362 let f = fixture().await;
363
364 let entries = commit_feed(
365 &handle(),
366 &f.public.name,
367 None,
368 &Actor::Anonymous,
369 &Stores::new(&f.orgs, &f.memberships, &f.repos),
370 &queries(),
371 )
372 .await
373 .expect("read")
374 .expect("visible");
375
376 assert_eq!(entries.len(), 1);
377 assert_eq!(entries[0].commit.summary, "first");
378 assert_eq!(entries[0].body, "why it happened");
379 }
380
381 #[tokio::test]
382 async fn a_private_repository_feeds_nothing_to_a_stranger_and_everything_to_its_owner() {
383 let f = fixture().await;
384
385 let anonymous = commit_feed(
386 &handle(),
387 &f.private.name,
388 None,
389 &Actor::Anonymous,
390 &Stores::new(&f.orgs, &f.memberships, &f.repos),
391 &queries(),
392 )
393 .await
394 .expect("read");
395
396 assert_eq!(
397 anonymous, None,
398 "a private feed must be indistinguishable from one that does not exist"
399 );
400
401 assert!(
402 commit_feed(
403 &handle(),
404 &f.private.name,
405 None,
406 &Actor::User(f.owner.clone()),
407 &Stores::new(&f.orgs, &f.memberships, &f.repos),
408 &queries(),
409 )
410 .await
411 .expect("read")
412 .is_some()
413 );
414 }
415
416 #[tokio::test]
417 async fn an_empty_repository_feeds_an_empty_list_rather_than_failing() {
418 let f = fixture().await;
419
420 let entries = commit_feed(
421 &handle(),
422 &f.public.name,
423 None,
424 &Actor::Anonymous,
425 &Stores::new(&f.orgs, &f.memberships, &f.repos),
426 &InMemoryGitQuery::empty(),
427 )
428 .await
429 .expect("read")
430 .expect("visible");
431
432 assert!(entries.is_empty());
433 }
434
435 // --- issues -------------------------------------------------------------------
436
437 #[tokio::test]
438 async fn the_issue_feed_carries_both_states_newest_first_with_bodies() {
439 let f = fixture().await;
440 open_issue(&f, 1, "oldest", 1_000).await;
441 let mut closed = open_issue(&f, 2, "middle", 2_000).await;
442 closed.close(at(3_000));
443 f.issues.save(&closed).await.expect("save issue");
444 open_issue(&f, 3, "newest", 4_000).await;
445
446 let entries = issue_feed(
447 &Actor::Anonymous,
448 &handle(),
449 &f.public.name,
450 &Stores::new(&f.orgs, &f.memberships, &f.repos),
451 &f.issues,
452 &f.users,
453 )
454 .await
455 .expect("read")
456 .expect("visible");
457
458 assert_eq!(
459 entries
460 .iter()
461 .map(|entry| entry.title.as_str())
462 .collect::<Vec<_>>(),
463 vec!["newest", "middle", "oldest"],
464 "a closed issue still happened, so it stays in the feed in date order"
465 );
466 assert_eq!(entries[0].body, "a **body**");
467 assert_eq!(entries[0].author.as_ref().map(OrgName::as_str), Some("ada"));
468 assert!(!entries[1].state.is_open());
469 }
470
471 #[tokio::test]
472 async fn a_private_repository_feeds_no_issues_to_a_stranger() {
473 let f = fixture().await;
474
475 assert_eq!(
476 issue_feed(
477 &Actor::Anonymous,
478 &handle(),
479 &f.private.name,
480 &Stores::new(&f.orgs, &f.memberships, &f.repos),
481 &f.issues,
482 &f.users,
483 )
484 .await
485 .expect("read"),
486 None
487 );
488 }
489
490 // --- activity -----------------------------------------------------------------
491
492 #[tokio::test]
493 async fn the_activity_feed_hides_pushes_to_repositories_a_stranger_cannot_see() {
494 let f = fixture().await;
495 push(&f, &f.public, "main", 1_000).await;
496 push(&f, &f.private, "main", 2_000).await;
497
498 let entries = activity_feed(
499 &handle(),
500 &Actor::Anonymous,
501 &Stores::new(&f.orgs, &f.memberships, &f.repos),
502 &f.events,
503 &f.users,
504 )
505 .await
506 .expect("read")
507 .expect("the handle exists");
508
509 assert_eq!(entries.len(), 1);
510 assert_eq!(entries[0].repo.as_str(), "steid");
511 assert_eq!(entries[0].pusher.as_ref().map(OrgName::as_str), Some("ada"));
512 }
513
514 #[tokio::test]
515 async fn the_owner_sees_their_private_pushes_newest_first() {
516 let f = fixture().await;
517 push(&f, &f.public, "main", 1_000).await;
518 push(&f, &f.private, "main", 2_000).await;
519
520 let entries = activity_feed(
521 &handle(),
522 &Actor::User(f.owner.clone()),
523 &Stores::new(&f.orgs, &f.memberships, &f.repos),
524 &f.events,
525 &f.users,
526 )
527 .await
528 .expect("read")
529 .expect("the handle exists");
530
531 assert_eq!(
532 entries
533 .iter()
534 .map(|entry| entry.repo.as_str())
535 .collect::<Vec<_>>(),
536 vec!["secret", "steid"]
537 );
538 }
539
540 #[tokio::test]
541 async fn an_unknown_handle_has_no_activity_feed_at_all() {
542 let f = fixture().await;
543
544 assert_eq!(
545 activity_feed(
546 &OrgName::new("nobody").expect("valid"),
547 &Actor::Anonymous,
548 &Stores::new(&f.orgs, &f.memberships, &f.repos),
549 &f.events,
550 &f.users,
551 )
552 .await
553 .expect("read"),
554 None,
555 "no such handle is a 404, not an empty feed"
556 );
557
558 // And a handle that exists but has nothing to show is the other answer.
559 assert_eq!(
560 activity_feed(
561 &f.org.name,
562 &Actor::Anonymous,
563 &Stores::new(&f.orgs, &f.memberships, &f.repos),
564 &f.events,
565 &f.users,
566 )
567 .await
568 .expect("read"),
569 Some(Vec::new())
570 );
571 }
572}