steid

@jamesgill /

feat: list repositories on the profile

`list_repos` plus the Repositories section that consumes it. A repository created
through the form now appears on the profile, which is what Milestone 3 exists for.

Filtering is the use case's, not the port's: `list_by_org` still returns everything and
`list_repos` drops what the viewer may not see, so the page and the `/api` route landing
next cannot apply different rules. Membership is resolved once per listing rather than
per row — the shape that invites that mistake is a filter inside a loop that can await.

`Option<Vec<_>>` rather than `Vec<_>`: `None` is an unknown handle and `Some(vec![])` a
handle whose repositories the viewer cannot see. The page cannot tell the difference —
it resolves the profile first — but `/api/users/{handle}/repos` has to answer 404 rather
than `[]` for a user who does not exist, and retrofitting that later means changing
every caller.

`RepoSummary` is deliberately not `RepoView`: the owning handle and `viewer_is_owner`
are constant across a listing and already known to the page.

One empty state covers both "no repositories" and "none you may see". A distinct message
for the second, or any count, would leak that private repositories exist and how many —
there is a test pinning that, because it is exactly what a later helpful tweak would
undo.

190 tests, clippy clean. Verified in a browser against a throwaway database and data
directory: the owner sees three repositories ordered by name with the private one
badged; signed out shows two, with no badge, no owner controls, and no trace of the
private repository's name or description. The dev database and ./data were not touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 8 days agoparent64222c3Browse files0c5ca49bb7d6b425aa6c0614342727d303f4c0eb

6 files changed+277 −10

plans/current.md+7 −1View file
@@ -26,7 +26,7 @@ problem twice as interesting, so not in the first pass.
2626 bare repo
2727 - [x] Application: `view_repo` read model — visibility-aware
2828 - [x] Web: `/{handle}/repos/new` form, `/{handle}/repos/{name}` page
29- [ ] Application: `list_repos` + the profile's Repositories section listing what the
29+- [x] Application: `list_repos` + the profile's Repositories section listing what the
3030 viewer may see
3131 - [ ] `/api/users/{handle}/repos`
3232
@@ -39,6 +39,12 @@ milestone 4.
3939
4040 ### Settled
4141
42+- **`list_repos` returns `Option<Vec<_>>`.** `None` is an unknown handle,
43+ `Some(vec![])` a handle whose repositories the viewer cannot see. `/api` needs that
44+ difference to answer 404 rather than `[]` for a user who does not exist.
45+- **`RepoSummary` is separate from `RepoView`.** The owning handle and
46+ `viewer_is_owner` are constant across a listing and already known to the page, so a
47+ listing type carries neither.
4248 - **`list_repos` was split from `view_repo`** and moved to the listing step. `view_repo`
4349 had a consumer immediately; `list_repos` would have been a third read model with no
4450 caller, which is what the previous three steps already were.
plans/progress.md+6 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5182 tests. Active milestone in [current.md](current.md).
5+190 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -134,6 +134,11 @@ it. How git is invoked is recorded in
134134 - **`tempfile` for test fixtures, not `target/`.** Parallel-safe by construction and
135135 self-cleaning on panic. Debris under `target/` would be actively harmful here, since
136136 `init_bare` refuses a path that already exists.
137+- **Membership is resolved once per listing, not once per row.** Obvious in hindsight;
138+ the shape that invites the mistake is filtering inside a loop that can `await`.
139+- **One empty state serves "no repositories" and "none you may see".** A distinct
140+ message for the second — or any count — leaks that private repositories exist and how
141+ many. Tested, because it is the kind of thing a later "helpful" tweak would undo.
137142 - **`redirect()` is a 307, and 307 preserves the method.** Post/redirect/get needs a
138143 303, or the browser re-POSTs the form to its redirect target. Milestone 2's settings
139144 form shipped with this and nothing caught it — every test passed, because the tests
src/application/mod.rs+1 −1View file
@@ -20,5 +20,5 @@ pub use error::{Error, Result};
2020 pub use identity::{Identity, describe_identity};
2121 pub use login::login;
2222 pub use profile::{PublicProfile, update_profile, view_profile};
23pub use repo::{NewRepo, RepoView, create_repo, view_repo};
23+pub use repo::{NewRepo, RepoSummary, RepoView, create_repo, list_repos, view_repo};
2424 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
src/application/repo.rs+200 −0View file
@@ -150,6 +150,57 @@ pub async fn view_repo(
150150 }))
151151 }
152152
153+/// A repository as it appears in a listing.
154+///
155+/// Leaner than [`RepoView`] on purpose: the owning handle and whether the viewer owns
156+/// it are constant across a listing and already known to whatever is rendering it.
157+#[derive(Debug, Clone, PartialEq, Eq)]
158+pub struct RepoSummary {
159+ pub name: RepoName,
160+ pub description: Option<String>,
161+ pub visibility: Visibility,
162+}
163+
164+/// Every repository under a handle that the viewer is allowed to see, ordered by name.
165+///
166+/// `Ok(None)` means no such handle — distinct from `Ok(Some(vec![]))`, which means the
167+/// handle exists and the viewer can see nothing under it. A caller serving `/api` needs
168+/// that difference to answer 404 rather than an empty list.
169+///
170+/// **The filtering happens here, not in the port.** `list_by_org` deliberately returns
171+/// everything, so that the page and `/api` cannot end up applying different rules.
172+/// A viewer who may see nothing gets an empty list, never a count or a hint — that
173+/// would leak both the existence and the number of private repositories.
174+pub async fn list_repos(
175+ handle: &OrgName,
176+ actor: &Actor,
177+ orgs: &impl OrgRepository,
178+ memberships: &impl MembershipRepository,
179+ repos: &impl RepoRepository,
180+) -> Result<Option<Vec<RepoSummary>>> {
181+ let Some(org) = orgs.find_by_name(handle).await? else {
182+ return Ok(None);
183+ };
184+
185+ // Resolved once rather than per row: membership cannot change mid-listing, and
186+ // asking per repository would be a query per repository.
187+ let is_member = is_org_member(&org, actor, memberships).await?;
188+
189+ Ok(Some(
190+ repos
191+ .list_by_org(&org.id)
192+ .await?
193+ .into_iter()
194+ .filter(|repo| repo.visibility.is_public() || is_member)
195+ .map(|repo| RepoSummary {
196+ name: repo.name,
197+ description: repo.description,
198+ visibility: repo.visibility,
199+ })
200+ .collect(),
201+ ))
202+}
203+
153204 fn taken() -> Error {
154205 DomainError::AlreadyExists {
155206 entity: "repository",
@@ -731,4 +782,153 @@ mod tests {
731782
732783 assert!(f.view(&Actor::Anonymous, "myrepo").await.is_some());
733784 }
785+
786+ // --- list_repos ------------------------------------------------------------
787+
788+ impl Fixture {
789+ async fn list(&self, actor: &Actor) -> Vec<RepoSummary> {
790+ list_repos(
791+ &self.handle,
792+ actor,
793+ &self.orgs,
794+ &self.memberships,
795+ &self.repos,
796+ )
797+ .await
798+ .expect("listing should not error")
799+ .expect("the handle exists")
800+ }
801+
802+ fn names(summaries: &[RepoSummary]) -> Vec<&str> {
803+ summaries.iter().map(|repo| repo.name.as_str()).collect()
804+ }
805+ }
806+
807+ /// Two public and one private, created out of alphabetical order.
808+ async fn mixed() -> Fixture {
809+ let f = fixture().await;
810+ f.create_with(Visibility::Public, "zebra").await;
811+ f.create_with(Visibility::Private, "secret").await;
812+ f.create_with(Visibility::Public, "alpha").await;
813+ f
814+ }
815+
816+ #[tokio::test]
817+ async fn outsiders_see_only_public_repositories() {
818+ let f = mixed().await;
819+
820+ for actor in [&Actor::Anonymous, &f.stranger] {
821+ let listed = f.list(actor).await;
822+ assert_eq!(
823+ Fixture::names(&listed),
824+ vec!["alpha", "zebra"],
825+ "{actor:?} should see only the public repositories"
826+ );
827+ }
828+ }
829+
830+ #[tokio::test]
831+ async fn members_and_owners_see_private_repositories_too() {
832+ let f = mixed().await;
833+
834+ for actor in [&f.member, &f.owner] {
835+ let listed = f.list(actor).await;
836+ assert_eq!(
837+ Fixture::names(&listed),
838+ vec!["alpha", "secret", "zebra"],
839+ "{actor:?} should see everything"
840+ );
841+ }
842+ }
843+
844+ #[tokio::test]
845+ async fn listings_are_ordered_by_name() {
846+ // Created zebra, secret, alpha — the order out is not the order in.
847+ let f = mixed().await;
848+
849+ assert_eq!(
850+ Fixture::names(&f.list(&f.owner).await),
851+ vec!["alpha", "secret", "zebra"]
852+ );
853+ }
854+
855+ #[tokio::test]
856+ async fn a_viewer_who_may_see_nothing_gets_an_empty_list() {
857+ // Not a count, not a hint. Either would leak that private repositories exist
858+ // and how many.
859+ let f = fixture().await;
860+ f.create_with(Visibility::Private, "secret").await;
861+ f.create_with(Visibility::Private, "other").await;
862+
863+ assert!(f.list(&Actor::Anonymous).await.is_empty());
864+ }
865+
866+ #[tokio::test]
867+ async fn a_handle_with_no_repositories_lists_nothing() {
868+ let f = fixture().await;
869+
870+ assert!(f.list(&f.owner).await.is_empty());
871+ }
872+
873+ #[tokio::test]
874+ async fn an_unknown_handle_is_none_not_an_empty_list() {
875+ // `/api` has to answer 404 for a handle that does not exist rather than `[]`.
876+ let f = fixture().await;
877+ let missing = OrgName::new("nobody").expect("valid handle");
878+
879+ let listed = list_repos(&missing, &f.owner, &f.orgs, &f.memberships, &f.repos)
880+ .await
881+ .expect("listing should not error");
882+
883+ assert!(listed.is_none());
884+ }
885+
886+ #[tokio::test]
887+ async fn a_summary_carries_what_a_listing_renders() {
888+ let f = fixture().await;
889+ f.create(
890+ &f.owner,
891+ &NewRepo {
892+ name: "steid".to_owned(),
893+ description: Some("A gitforge.".to_owned()),
894+ visibility: Visibility::Private,
895+ },
896+ )
897+ .await
898+ .expect("should create");
899+
900+ let listed = f.list(&f.owner).await;
901+ let summary = listed.first().expect("one repository");
902+
903+ assert_eq!(summary.name.as_str(), "steid");
904+ assert_eq!(summary.description.as_deref(), Some("A gitforge."));
905+ assert_eq!(summary.visibility, Visibility::Private);
906+ }
907+
908+ #[tokio::test]
909+ async fn listing_only_covers_the_handle_asked_for() {
910+ let f = fixture().await;
911+ f.create_with(Visibility::Public, "mine").await;
912+
913+ let other = Organization::new(OrgId::generate(), "other-org", None).expect("valid org");
914+ f.orgs.save(&other).await.expect("save org");
915+ f.repos
916+ .save(
917+ &Repository::new(
918+ RepoId::generate(),
919+ other.id.clone(),
920+ "theirs",
921+ None,
922+ Visibility::Public,
923+ )
924+ .expect("valid repo"),
925+ )
926+ .await
927+ .expect("save repo");
928+
929+ assert_eq!(
930+ Fixture::names(&f.list(&Actor::Anonymous).await),
931+ vec!["mine"]
932+ );
933+ }
734934 }
src/infrastructure/web/profile.rs+19 −6View file
@@ -15,12 +15,15 @@ use topcoat::{
1515 };
1616
1717 use crate::{
18 application::{PublicProfile, view_profile},
18+ application::{PublicProfile, list_repos, view_profile},
1919 components::button::{ButtonSize, ButtonVariant, button_variants},
2020 domain::OrgName,
2121 };
2222
23use super::context::{current_actor, memberships, orgs, server_error};
23+use super::{
24+ context::{current_actor, memberships, orgs, repos, server_error},
25+ repo::repo_list,
26+};
2427
2528 /// `{handle}` from the path. The struct name snake-cased is the parameter name.
2629 ///
@@ -49,6 +52,19 @@ pub(super) async fn profile_for(cx: &Cx) -> Result<PublicProfile> {
4952 async fn profile(cx: &Cx) -> Result {
5053 let profile = profile_for(cx).await?;
5154
55+ // The handle resolved above, so `None` here is impossible; an empty list is the
56+ // right answer either way rather than a 500.
57+ let listed = list_repos(
58+ &profile.handle,
59+ &current_actor(cx).await?,
60+ &orgs(cx),
61+ &memberships(cx),
62+ &repos(cx),
63+ )
64+ .await
65+ .map_err(server_error)?
66+ .unwrap_or_default();
67+
5268 view! {
5369 <header class="mb-10">
5470 <h1 class="text-2xl font-semibold tracking-tight">(&profile.label)</h1>
@@ -89,10 +105,7 @@ async fn profile(cx: &Cx) -> Result {
89105 >"New repository"</a>
90106 }
91107 </div>
92 // Still a placeholder: listing arrives with `list_repos` in the next step.
93 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
94 "Nothing here yet."
95 </p>
108+ repo_list(handle: profile.handle.as_str(), repos: &listed)
96109 </section>
97110
98111 <section class="mt-8">
src/infrastructure/web/repo.rs+44 −1View file
@@ -18,7 +18,7 @@ use topcoat::{
1818 };
1919
2020 use crate::{
21 application::{Error, NewRepo, RepoView, create_repo, view_repo},
21+ application::{Error, NewRepo, RepoSummary, RepoView, create_repo, view_repo},
2222 components::{
2323 badge::{BadgeVariant, badge},
2424 button::button,
@@ -276,3 +276,46 @@ async fn new_repo_form(
276276 </form>
277277 }
278278 }
279+
280+/// The Repositories section of a profile.
281+///
282+/// Takes the already-filtered summaries rather than fetching: which repositories a
283+/// viewer may see is [`list_repos`](crate::application::list_repos)'s decision, and a
284+/// component that queried for itself would be a second place that rule could live.
285+///
286+/// One empty state serves both "no repositories" and "none you may see" — a distinct
287+/// message for the second would leak that private repositories exist.
288+#[component]
289+pub(super) async fn repo_list(handle: &str, repos: &[RepoSummary]) -> Result {
290+ view! {
291+ if repos.is_empty() {
292+ <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
293+ "Nothing here yet."
294+ </p>
295+ } else {
296+ <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
297+ for repo in repos {
298+ <li class="px-4 py-3">
299+ <div class="flex items-baseline gap-2">
300+ <a
301+ href=(format!("/{handle}/repos/{}", repo.name))
302+ class="font-medium hover:underline"
303+ >(repo.name.as_str())</a>
304+ if !repo.visibility.is_public() {
305+ badge(variant: BadgeVariant::Outline, "Private")
306+ }
307+ </div>
308+ ({
309+ match &repo.description {
310+ Some(description) => view! {
311+ <p class="mt-0.5 text-sm text-muted-foreground">(description)</p>
312+ },
313+ None => view! {},
314+ }
315+ }?)
316+ </li>
317+ }
318+ </ul>
319+ }
320+ }
321+}