gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (federation): Expose a Person's public repositories collection

e9cb6688c63cba938cd7e83cefb137e9e9a4ef3e · Michael Hainz · 2026-07-16T17:42:05Z

Changes

8 files changed, +182 -6

MODIFY docs/README.md +3 -0
diff --git a/docs/README.md b/docs/README.md
index 26bd534..7a9be03 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/admins/federation.md b/docs/admins/federation.md
index 1027557..3ae7b80 100644
--- a/docs/admins/federation.md
+++ b/docs/admins/federation.md
@@ -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
diff --git a/docs/maintainers/federation-roadmap.md b/docs/maintainers/federation-roadmap.md
new file mode 100644
index 0000000..5407bbf
--- /dev/null
+++ b/docs/maintainers/federation-roadmap.md
@@ -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
diff --git a/docs/maintainers/forgefed.md b/docs/maintainers/forgefed.md
index 29930ad..ac0a56a 100644
--- a/docs/maintainers/forgefed.md
+++ b/docs/maintainers/forgefed.md
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActivityPubResource.java b/src/main/java/de/workaround/federation/ActivityPubResource.java
index bca87d5..519094a 100644
--- a/src/main/java/de/workaround/federation/ActivityPubResource.java
+++ b/src/main/java/de/workaround/federation/ActivityPubResource.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActorDocuments.java b/src/main/java/de/workaround/federation/ActorDocuments.java
index 11b7a62..306d171 100644
--- a/src/main/java/de/workaround/federation/ActorDocuments.java
+++ b/src/main/java/de/workaround/federation/ActorDocuments.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActorUris.java b/src/main/java/de/workaround/federation/ActorUris.java
index 3f99992..1aea99a 100644
--- a/src/main/java/de/workaround/federation/ActorUris.java
+++ b/src/main/java/de/workaround/federation/ActorUris.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/FederationActorsTest.java b/src/test/java/de/workaround/federation/FederationActorsTest.java
index ad0a2e1..ed96615 100644
--- a/src/test/java/de/workaround/federation/FederationActorsTest.java
+++ b/src/test/java/de/workaround/federation/FederationActorsTest.java
@@ -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)

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog