✨ (federation): Expose a Person's public repositories collection
Changes
8 files changed, +182 -6
MODIFY
docs/README.md
+3 -0
@@ -70,6 +70,9 @@
70
70
visibility-guarded serving endpoint.
71
71
- **[ForgeFed architecture](maintainers/forgefed.md)** — how federation is
72
72
implemented, the decisions behind it, what works and what is still missing.
73
+- **[Federated collaboration roadmap](maintainers/federation-roadmap.md)** — the
74
+ plan to grow federation from follow-and-feed into cross-instance discovery,
75
+ forking, and merge requests: the stories, protocol choice, and prerequisites.
73
76
- **[Push mirrors architecture](maintainers/push-mirrors.md)** — trigger flow,
74
77
queue design, credential encryption, and SSH decisions behind push mirroring.
75
78
- **[CI/CD runner protocol](maintainers/ci-runners.md)** — how the Forgejo/Gitea
MODIFY
docs/admins/federation.md
+1 -0
@@ -63,6 +63,7 @@
63
63
| `GET /ap/users/{username}` | ActivityPub `Person` actor |
64
64
| `GET /ap/instance` | Instance `Application` actor |
65
65
| `GET …/outbox`, `…/followers` | Activity and follower collections |
66
+| `GET /ap/users/{username}/repositories` | A user's public repository actors (discovery) |
66
67
| `POST …/inbox` | Signed inbound activities (repo, user, and instance inboxes) |
67
68
68
69
Private repositories are never exposed. Inbound posts are verified (HTTP
ADD
docs/maintainers/federation-roadmap.md
+109 -0
@@ -0,0 +1,109 @@
1
+# Federated collaboration: roadmap
2
+
3
+Forward-looking plan for growing federation from **follow-and-feed** (what ships
4
+today) into **cross-instance collaboration** — discovering people's work on other
5
+instances, forking it, and contributing back with a merge request that travels
6
+over ForgeFed.
7
+
8
+This document is the *why* and the *sequence*. For how the current subsystem is
9
+built see [ForgeFed architecture](forgefed.md); for operating it see the
10
+[deployment guide](../admins/federation.md); for the user view see the
11
+[user guide](../users/federation.md).
12
+
13
+---
14
+
15
+## The vision
16
+
17
+A contributor on instance A wants to help with a project on instance B without
18
+opening an account on B:
19
+
20
+1. **Follow the author** on B and see all their public repositories aggregated
21
+ in one place, with recent activity.
22
+2. **Click through** to a repository's activity and open it.
23
+3. **Fork** it onto instance A (their home instance).
24
+4. Push a contribution branch to the fork and **open a merge request** back
25
+ against the upstream on B — over federation, no account on B.
26
+5. See the outcome (accepted / rejected / merged) back on A.
27
+
28
+Steps 1–3 are mostly discovery and plumbing that build on existing pieces.
29
+Step 4 is the protocol epic.
30
+
31
+---
32
+
33
+## Where we start
34
+
35
+Today federation is **repository-follow + push feed** (see the "What works today"
36
+list in [forgefed.md](forgefed.md)). You follow *repositories*, not people; there
37
+is no cross-instance fork and no federated merge request. Local issues, MRs, and
38
+forks do not cross the instance boundary.
39
+
40
+---
41
+
42
+## Stories
43
+
44
+### Story 1 — Follow a user, see their aggregated repositories *(in progress)*
45
+
46
+Follow a remote `Person` actor and see their public repositories, grouped, with
47
+recent push activity per repo — instead of following each repository one by one.
48
+
49
+Needs:
50
+- ✅ `Person` actor exposes a `repositories` collection (`/ap/users/{username}/repositories`,
51
+ public repos only) and advertises it in the actor document — **done**.
52
+- Outbound follow generalised to a `Person` target (today it targets repository
53
+ actors only).
54
+- Persist followed remote users; fetch and cache their repository collection.
55
+- `/following` UI groups activity by user → repository.
56
+
57
+Independently shippable, no merge-request risk. This is the next increment.
58
+
59
+### Story 2 — Cross-instance fork with upstream tracking *(issue #12)*
60
+
61
+Fork a remote public repository into the caller's local namespace; persist the
62
+upstream link (remote actor URL + clone URL). The clone works over plain git
63
+today — the new work is the upstream model and the UI surface.
64
+
65
+Key risk: cloning a **remote-supplied** git URL is a new outbound network
66
+surface. `RemoteUrlGuard` today guards HTTP fetches only; git clone/fetch must
67
+get the same SSRF discipline. Security review required.
68
+
69
+### Story 3 — Merge request via federation *(issue #13, epic)*
70
+
71
+The submitter forks upstream (Story 2), pushes a branch, and opens a merge
72
+request against the upstream repo on the other instance; the outcome flows back.
73
+
74
+**Protocol shape: fork-and-pull via `Offer(Branch)`** (chosen over patch-offer).
75
+
76
+| Option | How | Verdict |
77
+|---|---|---|
78
+| **A. Patch-offer** | `Offer{Ticket + embedded patch}` to upstream inbox; target rebuilds MR from the diff | Simpler wire, but no shared git objects; large patches are ugly. **Rejected for v1.** |
79
+| **B. Fork-and-pull** | Fork → push branch → `Offer{Branch}` carrying the branch fetch URL; target `git fetch`es it and opens a local MR referencing it | Matches the user's mental model, keeps real git objects (reviewers see real commits/diffs, can pull the branch), degrades gracefully. **Chosen.** |
80
+
81
+Build order inside Story 3:
82
+1. Outbound `Offer(Branch)` — new activity type, enqueued via `DeliveryService`.
83
+2. Inbound `OfferHandler` — validate, `git fetch` submitter branch (SSRF-guarded),
84
+ create a local MR referencing the remote branch.
85
+3. Status back-channel — `Accept`/`Reject`/merge-notify so the submitter sees
86
+ the outcome.
87
+
88
+---
89
+
90
+## Cross-cutting prerequisites
91
+
92
+These bite across Stories 2–3 and should be tracked as they land:
93
+
94
+- **Outbound git fetch/clone SSRF guard** — extend `RemoteUrlGuard` (or a sibling)
95
+ to cover the git transport, not just HTTP. New attack surface introduced by
96
+ Story 2, reused by Story 3.
97
+- **Actor lifecycle** (`Delete`/`Update`/`Move`) — still on the gap list in
98
+ [forgefed.md](forgefed.md). A federated MR that references a deleted or moved
99
+ remote fork goes stale silently until this exists.
100
+- **Interop scope** — everything here targets **git-shark ↔ git-shark** first.
101
+ Forgejo/Vervis federation is early and likely incompatible; broadening is a
102
+ later, separately-scoped effort.
103
+
104
+## Explicitly out of scope (for now)
105
+
106
+- Federated review comments / inline discussion on a merge request.
107
+- Open (non-allowlisted) federation — the mutual peer allowlist stays the trust
108
+ boundary throughout this roadmap.
109
+- Keeping a fork continuously in sync with its upstream beyond the initial clone.
MODIFY
docs/maintainers/forgefed.md
+12 -6
@@ -3,7 +3,8 @@
3
3
Maintainer-facing documentation for the federation subsystem: how it is built,
4
4
why it is built that way, what works today, and what is still missing. For
5
5
operating it see the [deployment guide](../admins/federation.md); for the user
6
-view see the [user guide](../users/federation.md).
6
+view see the [user guide](../users/federation.md); for where the subsystem is
7
+headed see the [federated collaboration roadmap](federation-roadmap.md).
7
8
8
9
git-shark speaks [ForgeFed](https://forgefed.org), the forge-federation
9
10
vocabulary on top of [ActivityPub](https://www.w3.org/TR/activitypub/). The
@@ -54,10 +55,12 @@
54
55
| User | `Person` | `/ap/users/{username}` | `acct:username@host` |
55
56
| Instance | `Application` | `/ap/instance` | — |
56
57
57
-Each has its own inbox; repositories and users also expose `outbox` and
58
-(repositories) `followers` collections. Private repositories are invisible to
59
-every federation endpoint — visibility is checked at the resource layer, not
60
-filtered in templates.
58
+Each has its own inbox; repositories and users also expose `outbox`,
59
+repositories additionally a `followers` collection, and users a `repositories`
60
+collection (`…/ap/users/{username}/repositories`) listing that user's public
61
+repository actors for cross-instance discovery. Private repositories are
62
+invisible to every federation endpoint — visibility is checked at the resource
63
+layer, not filtered in templates.
61
64
62
65
## Data flow
63
66
@@ -164,7 +167,10 @@
164
167
## What works today
165
168
166
169
- Actor documents and WebFinger discovery for repositories, users, and the
167
- instance; outbox and followers collections.
170
+ instance; outbox and followers collections. The `Person` actor advertises and
171
+ serves a `repositories` collection of its public repository actors
172
+ (foundation for following a user — see the
173
+ [federated collaboration roadmap](federation-roadmap.md), Story 1).
168
174
- Inbound `Follow`/`Undo(Follow)` on public repositories, answered with a
169
175
signed `Accept` (remote users can follow local repos).
170
176
- `Push` fan-out to remote followers from both git transports.
MODIFY
src/main/java/de/workaround/federation/ActivityPubResource.java
+13 -0
@@ -106,6 +106,19 @@
106
106
return json(documents.orderedCollection(id, payloads(FederationKey.ActorType.PERSON, user.id.toString())));
107
107
}
108
108
109
+ @GET
110
+ @Path("users/{username}/repositories")
111
+ public Response personRepositories(@PathParam("username") String username)
112
+ {
113
+ User user = requireUser(username);
114
+ String id = uris.repositories(uris.person(user));
115
+ List<Object> items = new ArrayList<>(repositories.listOwnedBy(user).stream()
116
+ .filter(repo -> repo.visibility == Repository.Visibility.PUBLIC)
117
+ .map(repo -> (Object) uris.repository(repo))
118
+ .toList());
119
+ return json(documents.orderedCollection(id, items));
120
+ }
121
+
109
122
private List<Object> payloads(FederationKey.ActorType type, String ref)
110
123
{
111
124
List<Object> items = new ArrayList<>();
MODIFY
src/main/java/de/workaround/federation/ActorDocuments.java
+1 -0
@@ -59,6 +59,7 @@
59
59
}
60
60
node.put("inbox", uris.inbox(id));
61
61
node.put("outbox", uris.outbox(id));
62
+ node.put("repositories", uris.repositories(id));
62
63
addPublicKey(node, id, FederationKey.ActorType.PERSON, user.id.toString());
63
64
return node;
64
65
}
MODIFY
src/main/java/de/workaround/federation/ActorUris.java
+5 -0
@@ -57,6 +57,11 @@
57
57
return actorId + "/followers";
58
58
}
59
59
60
+ public String repositories(String actorId)
61
+ {
62
+ return actorId + "/repositories";
63
+ }
64
+
60
65
public String keyId(String actorId)
61
66
{
62
67
return actorId + KEY_FRAGMENT;
MODIFY
src/test/java/de/workaround/federation/FederationActorsTest.java
+38 -0
@@ -65,6 +65,44 @@
65
65
}
66
66
67
67
@Test
68
+ void personActorAdvertisesRepositoriesCollection()
69
+ {
70
+ User user = persistUser("fed-act-ivy-" + unique());
71
+
72
+ given().accept(ACTIVITY_JSON)
73
+ .when().get("/ap/users/" + user.username)
74
+ .then().statusCode(200)
75
+ .body(containsString("\"type\":\"Person\""))
76
+ .body(containsString("/ap/users/" + user.username + "/repositories"));
77
+ }
78
+
79
+ @Test
80
+ void personRepositoriesCollectionListsOnlyPublicRepos()
81
+ {
82
+ User owner = persistUser("fed-act-jane-" + unique());
83
+ service.create(owner, "alpha", Repository.Visibility.PUBLIC, "first");
84
+ service.create(owner, "beta", Repository.Visibility.PUBLIC, "second");
85
+ service.create(owner, "hidden", Repository.Visibility.PRIVATE, "secret");
86
+
87
+ given().accept(ACTIVITY_JSON)
88
+ .when().get("/ap/users/" + owner.username + "/repositories")
89
+ .then().statusCode(200)
90
+ .body(containsString("\"type\":\"OrderedCollection\""))
91
+ .body(containsString("\"totalItems\":2"))
92
+ .body(containsString("/ap/repos/" + owner.username + "/alpha"))
93
+ .body(containsString("/ap/repos/" + owner.username + "/beta"))
94
+ .body(not(containsString("/ap/repos/" + owner.username + "/hidden")));
95
+ }
96
+
97
+ @Test
98
+ void personRepositoriesCollectionEmptyForUnknownUserIsNotFound()
99
+ {
100
+ given().accept(ACTIVITY_JSON)
101
+ .when().get("/ap/users/nobody-" + unique() + "/repositories")
102
+ .then().statusCode(404);
103
+ }
104
+
105
+ @Test
68
106
void instanceActorResolves()
69
107
{
70
108
given().accept(ACTIVITY_JSON)