steid

@jamesgill /

feat: list a handle's repositories over /api

Completes Milestone 3's last step. The listing route calls `list_repos`
directly rather than going through `profile_for`, so a handle that does not
exist 404s on the use case's own `None` while a handle whose repositories the
caller may not see answers `[]` — which is the distinction `list_repos` was
given an `Option` for in the first place. Going via the profile would have made
that `None` unreachable and left the two surfaces disagreeing about what an
empty portfolio means.

`handle_param` is split out of `profile_for` so both surfaces parse the segment
the same way; a malformed handle stays a 404 rather than a bad request.

The response is a bare array to match `/api/users/{handle}` returning a bare
object, and `visibility` is spelled through `Visibility::as_str` rather than a
derived `Serialize` — the wire spelling is the web layer's promise, not
something a domain rename should be free to change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 8 days agoparent0c5ca49Browse files8622a05ce8d4de4191ea264cb1e0b0cf82eb53b6

3 files changed+72 −6

plans/current.md+11 −1View file
@@ -28,7 +28,7 @@ problem twice as interesting, so not in the first pass.
2828 - [x] Web: `/{handle}/repos/new` form, `/{handle}/repos/{name}` page
2929 - [x] Application: `list_repos` + the profile's Repositories section listing what the
3030 viewer may see
31- [ ] `/api/users/{handle}/repos`
31+- [x] `/api/users/{handle}/repos`
3232
3333 ### Done when
3434
@@ -65,6 +65,16 @@ milestone 4.
6565 tie-break, and this milestone's own "Done when" puts repositories on the profile, so
6666 the consumer is inside the milestone rather than hypothetical. The per-repo analogue
6767 of `Organization::bio`.
68+- **`/api/users/{handle}/repos` answers with a bare array**, not an
69+ `{"repositories": […]}` envelope, matching `/api/users/{handle}` returning a bare
70+ object. The cost is knowingly taken: adding pagination later means a wrapper and a
71+ breaking change. Personal-first means a handful of repositories, and the API has no
72+ consumers to break yet.
73+- **`/api` resolves the handle without loading a profile.** `handle_param` was split
74+ out of `profile_for` so the listing route can 404 on `list_repos`' own `None` rather
75+ than on a profile lookup it does not otherwise need — which is what that `Option` was
76+ given a meaning for.
77+
6878 - **`list_by_org` returns every repository regardless of visibility.** Filtering is an
6979 authorization decision and belongs to the use case, so the page and `/api` cannot end
7080 up applying different rules. The cost is that a private repo is briefly in memory
src/infrastructure/web/api.rs+51 −2View file
@@ -8,10 +8,19 @@ use serde::Serialize;
88 use topcoat::{
99 Result,
1010 context::Cx,
11 router::{content::Json, error::unauthorized, route},
11+ router::{
12+ content::Json,
13+ error::{RouterErrorExt, unauthorized},
14+ route,
15+ },
1216 };
1317
14use super::context::identity;
18+use crate::application::list_repos;
19+
20+use super::{
21+ context::{current_actor, identity, memberships, orgs, repos, server_error},
22+ profile::handle_param,
23+};
1524
1625 /// The authenticated user, as JSON.
1726 ///
@@ -63,3 +72,43 @@ async fn user(cx: &Cx) -> Result<Json<Profile>> {
6372 viewer_is_owner: profile.viewer_is_owner,
6473 }))
6574 }
75+
76+/// A repository in a listing, as JSON.
77+///
78+/// Mirrors `RepoSummary`. `visibility` is rendered through `Visibility::as_str` rather
79+/// than a derived `Serialize` on the domain enum — the wire spelling is this layer's
80+/// promise to keep, not something a domain refactor should be free to rename.
81+#[derive(Debug, Serialize)]
82+struct Repo {
83+ name: String,
84+ description: Option<String>,
85+ visibility: String,
86+}
87+
88+/// The repositories under a handle that the caller may see.
89+///
90+/// 404 for a handle that does not exist; `[]` for one whose repositories the caller
91+/// may not see. That difference is the whole reason `list_repos` answers with an
92+/// `Option` — a private repository has to be absent, and absent has to look the same
93+/// as owning nothing.
94+#[route(GET "/api/users/{handle}/repos")]
95+async fn user_repos(cx: &Cx) -> Result<Json<Vec<Repo>>> {
96+ let handle = handle_param(cx)?;
97+ let actor = current_actor(cx).await?;
98+
99+ let listed = list_repos(&handle, &actor, &orgs(cx), &memberships(cx), &repos(cx))
100+ .await
101+ .map_err(server_error)?
102+ .ok_or_not_found()?;
103+
104+ Ok(Json(
105+ listed
106+ .into_iter()
107+ .map(|repo| Repo {
108+ name: repo.name.to_string(),
109+ description: repo.description,
110+ visibility: repo.visibility.as_str().to_owned(),
111+ })
112+ .collect(),
113+ ))
114+}
src/infrastructure/web/profile.rs+10 −3View file
@@ -33,13 +33,20 @@ use super::{
3333 #[path_param]
3434 struct Handle(str);
3535
36/// Resolves `{handle}` from the path into a profile, or 404.
36+/// Resolves `{handle}` from the path, or 404.
3737 ///
3838 /// A malformed handle 404s rather than erroring: `/Not A Handle` is a page that does
3939 /// not exist, not a bad request.
40+///
41+/// Shared with `/api`, which resolves the same segment without wanting a profile —
42+/// listing repositories under a handle needs the handle and nothing else.
43+pub(super) fn handle_param(cx: &Cx) -> Result<OrgName> {
44+ Ok(OrgName::new(path_param::<Handle>(cx)).map_err(|_| not_found())?)
45+}
46+
47+/// Resolves `{handle}` from the path into a profile, or 404.
4048 pub(super) async fn profile_for(cx: &Cx) -> Result<PublicProfile> {
41 let raw = path_param::<Handle>(cx);
42 let handle = OrgName::new(raw).map_err(|_| not_found())?;
49+ let handle = handle_param(cx)?;
4350 let actor = current_actor(cx).await?;
4451
4552 Ok(view_profile(&handle, &actor, &orgs(cx), &memberships(cx))