✨ (api): Reshape repository responses to the Gitea repository object
Changes
9 files changed, +203 -35
MODIFY
README.md
+2 -2
@@ -142,9 +142,9 @@
142
142
|---|---|---|
143
143
| GET | `/api/v1/version` | Emulated Gitea version (anonymous; gates Gitea-client feature use) |
144
144
| GET | `/api/v1/user` | The token owner in Gitea user shape (`401` without a valid token) |
145
-| GET | `/api/v1/repos` | Repositories visible to the caller |
145
+| GET | `/api/v1/repos` | Repositories visible to the caller (Gitea repository objects) |
146
146
| POST | `/api/v1/repos` | Create a repository (`400` invalid name, `409` duplicate) |
147
-| GET | `/api/v1/repos/{owner}/{name}` | Repository detail |
147
+| GET | `/api/v1/repos/{owner}/{name}` | Repository detail in Gitea shape (`full_name`, `default_branch`, `clone_url`, `permissions`, …) |
148
148
| DELETE | `/api/v1/repos/{owner}/{name}` | Delete a repository (owner only) |
149
149
| GET, POST | `/api/v1/repos/{owner}/{name}/issues` | List / create issues |
150
150
| GET, PATCH, DELETE | `/api/v1/repos/{owner}/{name}/issues/{number}` | Get / update status / delete an issue |
MODIFY
docs/README.md
+3 -0
@@ -89,6 +89,9 @@
89
89
- **[CI/CD runner protocol](maintainers/ci-runners.md)** — how the Forgejo/Gitea
90
90
`runner.v1` server side is built (Connect-over-JAX-RS, protobuf codegen), the
91
91
decisions behind it, and the works/gaps list toward full workflow execution.
92
+- **[Gitea-compatible REST API](maintainers/gitea-api.md)** — the migration of
93
+ `/api/v1` to the Gitea contract (so Renovate/`tea` can drive git-shark), the
94
+ component map, key decisions, and the works/gaps list.
92
95
93
96
## Where else to look
94
97
ADD
docs/maintainers/gitea-api.md
+80 -0
@@ -0,0 +1,80 @@
1
+# Gitea-compatible REST API
2
+
3
+git-shark's `/api/v1` is being migrated from a bespoke JSON shape to the
4
+**Gitea REST contract**, so Gitea-ecosystem tooling — [Renovate](https://docs.renovatebot.com/)
5
+first, then `tea`, `act_runner`, and assorted bots — can drive a git-shark
6
+instance with its stock `gitea`/`forgejo` platform driver and no git-shark-specific
7
+code. Tracked in issue #20.
8
+
9
+## Why migrate rather than run a shim
10
+
11
+The REST API has no external consumers yet, so reshaping it in place is a rename,
12
+not a breaking change — the cheapest possible moment to do it. A parallel
13
+Gitea shim next to the bespoke API would fight the existing routes for the same
14
+paths (`GET /api/v1/repos/{owner}/{name}` wants one JSON body, not two) and leave
15
+two schemas to maintain forever. One Gitea-native schema is less code and broader
16
+compatibility.
17
+
18
+The domain layer keeps its own vocabulary: internally these are **merge requests**
19
+(`MergeRequest*`), and only the wire speaks Gitea's "pull request". The mapping is
20
+confined to the DTO/resource layer in `de.workaround.api`.
21
+
22
+## Component map
23
+
24
+| Concern | Type | Notes |
25
+|---|---|---|
26
+| Auth | `ApiTokenAuthFilter` | Accepts both `Authorization: Bearer <PAT>` and the Gitea-style `Authorization: token <PAT>`; same personal access tokens as git-over-HTTP |
27
+| DTOs | `ApiModels` | All response records are Gitea-shaped; snake_case fields via `@JsonProperty`. **Shared with the MCP tools** — reshaping the REST body reshapes MCP tool output too (accepted; see decisions) |
28
+| Surrogate ids | `GiteaIds` | Folds a `UUID` PK into a stable non-negative `long` for Gitea's int64 `id` |
29
+| Version probe | `VersionApiResource` | `GET /api/v1/version`; string from `gitshark.gitea-api.version` |
30
+| Repositories | `RepositoryApiResource` | Gitea repository object incl. `owner`, `full_name`, `default_branch`, `clone_url`, `html_url`, `permissions`, merge flags |
31
+| User | `UserApiResource` | Self identity in Gitea user shape |
32
+| Search | `SearchApiResource` | git-shark-specific (not a Gitea endpoint); returns the email-free `PersonView` and a shallow repository projection |
33
+
34
+## Key decisions
35
+
36
+- **Reported version is deliberately low** (`gitshark.gitea-api.version`, default
37
+ `1.13.0`). Gitea clients gate feature calls on it: `>= 1.14.0` makes Renovate
38
+ call `requested_reviewers`, `>= 1.24.0` unlocks `delete_branch_after_merge` —
39
+ neither is implemented, so the version is kept below them until they are. Raise
40
+ it as capabilities land.
41
+- **Surrogate `id` is one-way.** `GiteaIds.of(uuid)` is a display value only;
42
+ git-shark never looks an entity up by it — owner/name and per-repo `number`
43
+ (Gitea's `index`) remain the real keys. The fold is lossy; do not reverse it.
44
+- **MCP shares the DTOs.** MCP tools return `ApiModels` records directly, so they
45
+ now emit Gitea-shaped JSON. Accepted (option (a) in the spike): the shape is
46
+ richer, not worse, for AI-agent consumers. MCP has no request base URL, so the
47
+ repository `clone_url`/`html_url` are null there while the REST resource fills
48
+ them from the request's external base URI.
49
+- **Fields git-shark has no feature for are hard-coded:** `archived` and `mirror`
50
+ are always false (no archive feature; push-mirrors are outbound, not incoming
51
+ mirrors), and the `allow_*` merge flags advertise merge commits only, matching
52
+ the one merge strategy the merge service implements.
53
+- **PII:** the self-scoped `UserView` carries `email`; the search `PersonView`
54
+ omits it, because search is anonymous and would otherwise disclose every
55
+ matched user's address.
56
+
57
+## What works today
58
+
59
+- `Authorization: token <PAT>` scheme alongside `Bearer`.
60
+- `GET /api/v1/version` — Gitea version probe.
61
+- `GET /api/v1/user` — self identity in Gitea user shape (`id`, `login`,
62
+ `username`, `full_name`, `email`).
63
+- `GET /api/v1/repos` and `GET /api/v1/repos/{owner}/{name}` — Gitea repository
64
+ objects, including `default_branch` (live git read), `clone_url`/`html_url`
65
+ (from the request base URL), `permissions`, `fork`/`parent`, and merge flags.
66
+
67
+## What still needs to be implemented
68
+
69
+- `GET /api/v1/repos/{owner}/{name}/branches/{branch}` — branch object.
70
+- `pulls` resource: rename `merge-requests` → `pulls`, reshape to Gitea pull
71
+ requests, add find-by-branch (`GET pulls/{base}/{head}`), `PATCH pulls/{index}`
72
+ (title/body/state), keep create/get/list/merge.
73
+- `GET labels` → `[]` stub and commit-status stubs (`POST /statuses/{sha}`,
74
+ `GET /commits/{ref}/statuses`) so Renovate proceeds.
75
+- `GET /repos/{owner}/{name}/contents/{path}` (Renovate mostly clones, so low
76
+ priority).
77
+- Issue open/closed mapping + issue-comment REST endpoints (dependency dashboard);
78
+ deferred — run Renovate with `dependencyDashboard: false`.
79
+- A real Renovate `LOG_LEVEL=debug` end-to-end run to validate JSON fidelity
80
+ (field-name mismatches fail silently).
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+46 -12
@@ -25,25 +25,59 @@
25
25
26
26
// -- responses --
27
27
28
- public record RepositoryView(String owner, String name, Repository.Visibility visibility, String description,
29
- Instant createdAt, String parentOwner, String parentName)
28
+ /** A repository owner (user or organisation) as Gitea's nested owner object; clients read {@code login}. */
29
+ public record OwnerView(long id, String login, String username)
30
30
{
31
- /** Projection with the parent hidden — safe default for listings where the viewer's read access is unknown. */
32
- public static RepositoryView of(Repository repo)
31
+ public static OwnerView of(Repository repo)
33
32
{
34
- return of(repo, false);
33
+ return new OwnerView(GiteaIds.of(repo.ownerId()), repo.ownerHandle(), repo.ownerHandle());
34
+ }
35
+ }
36
+
37
+ /** The caller's effective rights on a repository; Gitea clients gate writes (e.g. automerge) on {@code push}. */
38
+ public record PermissionsView(boolean admin, boolean push, boolean pull)
39
+ {
40
+ }
41
+
42
+ /**
43
+ * A repository in the Gitea contract. git-shark has no archive feature and its push-mirrors are outbound,
44
+ * so {@code archived}/{@code mirror} are always false; only merge-commit merges are implemented, so the
45
+ * {@code allow_*} flags advertise merge commits only. {@code cloneUrl}/{@code htmlUrl}/{@code defaultBranch}
46
+ * are supplied by the caller because they need the request's external base URL and a live git read.
47
+ */
48
+ public record RepositoryView(long id, String name, @JsonProperty("full_name") String fullName, OwnerView owner,
49
+ String description, @JsonProperty("private") boolean isPrivate, boolean fork, boolean mirror,
50
+ boolean archived, boolean empty, @JsonProperty("default_branch") String defaultBranch,
51
+ @JsonProperty("clone_url") String cloneUrl, @JsonProperty("html_url") String htmlUrl, RepositoryView parent,
52
+ PermissionsView permissions, @JsonProperty("allow_merge_commits") boolean allowMergeCommits,
53
+ @JsonProperty("allow_rebase") boolean allowRebase, @JsonProperty("allow_squash_merge") boolean allowSquashMerge,
54
+ Instant createdAt)
55
+ {
56
+ /**
57
+ * A shallow projection used only as a fork's {@code parent}: DB-derived fields only, no git read or URLs.
58
+ * Its own parent is always null so the shape never recurses.
59
+ */
60
+ public static RepositoryView shallow(Repository repo)
61
+ {
62
+ return new RepositoryView(GiteaIds.of(repo.id), repo.name, repo.ownerHandle() + "/" + repo.name,
63
+ OwnerView.of(repo), repo.description, repo.visibility == Repository.Visibility.PRIVATE,
64
+ repo.parent != null, false, false, false, null, null, null, null, null, true, false, false,
65
+ repo.createdAt);
35
66
}
36
67
37
68
/**
38
- * @param showParent whether the caller may see the fork's parent; when false (or the repo is not a
39
- * fork) {@code parentOwner}/{@code parentName} are null, so a source turned private is never
40
- * disclosed through its forks.
69
+ * @param showParent whether the caller may see this fork's parent; when false (or the repo is not a
70
+ * fork) {@code parent} is null, so a source turned private is never disclosed through its forks.
41
71
*/
42
- public static RepositoryView of(Repository repo, boolean showParent)
72
+ public static RepositoryView of(Repository repo, boolean showParent, boolean empty, String defaultBranch,
73
+ String cloneUrl, String htmlUrl, PermissionsView permissions)
43
74
{
44
- boolean parent = showParent && repo.parent != null;
45
- return new RepositoryView(repo.ownerHandle(), repo.name, repo.visibility, repo.description,
46
- repo.createdAt, parent ? repo.parent.ownerHandle() : null, parent ? repo.parent.name : null);
75
+ boolean fork = repo.parent != null;
76
+ RepositoryView parent = showParent && fork ? shallow(repo.parent) : null;
77
+ return new RepositoryView(GiteaIds.of(repo.id), repo.name, repo.ownerHandle() + "/" + repo.name,
78
+ OwnerView.of(repo), repo.description, repo.visibility == Repository.Visibility.PRIVATE, fork, false,
79
+ false, empty, defaultBranch, cloneUrl, htmlUrl, parent, permissions, true, false, false,
80
+ repo.createdAt);
47
81
}
48
82
}
49
83
MODIFY
src/main/java/de/workaround/api/RepositoryApiResource.java
+31 -4
@@ -4,6 +4,7 @@
4
4
import java.util.List;
5
5
6
6
import de.workaround.git.AccessPolicy;
7
+import de.workaround.git.GitBrowseService;
7
8
import de.workaround.git.GitRepositoryService;
8
9
import de.workaround.git.InvalidRepositoryNameException;
9
10
import de.workaround.git.RepositoryAlreadyExistsException;
@@ -18,8 +19,10 @@
18
19
import jakarta.ws.rs.Path;
19
20
import jakarta.ws.rs.PathParam;
20
21
import jakarta.ws.rs.Produces;
22
+import jakarta.ws.rs.core.Context;
21
23
import jakarta.ws.rs.core.MediaType;
22
24
import jakarta.ws.rs.core.Response;
25
+import jakarta.ws.rs.core.UriInfo;
23
26
24
27
/**
25
28
* JSON REST access to repositories under {@code /api/v1/repos}. Reads follow the same visibility rule as the
@@ -34,15 +37,21 @@
34
37
GitRepositoryService service;
35
38
36
39
@Inject
40
+ GitBrowseService browse;
41
+
42
+ @Inject
37
43
AccessPolicy accessPolicy;
38
44
39
45
@Inject
40
46
ApiPrincipal principal;
41
47
48
+ @Context
49
+ UriInfo uriInfo;
50
+
42
51
@GET
43
52
public List<ApiModels.RepositoryView> list()
44
53
{
45
- return service.listVisibleTo(principal.orNull()).stream().map(ApiModels.RepositoryView::of).toList();
54
+ return service.listVisibleTo(principal.orNull()).stream().map(this::view).toList();
46
55
}
47
56
48
57
@POST
@@ -58,7 +67,7 @@
58
67
{
59
68
Repository repo = service.create(user, request.name(), visibility, description);
60
69
return Response.created(URI.create("/api/v1/repos/" + user.username + "/" + repo.name))
61
- .entity(ApiModels.RepositoryView.of(repo)).build();
70
+ .entity(view(repo)).build();
62
71
}
63
72
catch (InvalidRepositoryNameException e)
64
73
{
@@ -77,7 +86,7 @@
77
86
public ApiModels.RepositoryView get(@PathParam("owner") String owner, @PathParam("name") String name)
78
87
{
79
88
Repository repo = requireReadable(owner, name);
80
- return ApiModels.RepositoryView.of(repo, canSeeParent(repo));
89
+ return view(repo);
81
90
}
82
91
83
92
@POST
@@ -90,7 +99,7 @@
90
99
{
91
100
Repository forked = service.fork(user, source);
92
101
return Response.created(URI.create("/api/v1/repos/" + user.username + "/" + forked.name))
93
- .entity(ApiModels.RepositoryView.of(forked, canSeeParent(forked))).build();
102
+ .entity(view(forked)).build();
94
103
}
95
104
catch (RepositoryAlreadyExistsException e)
96
105
{
@@ -110,6 +119,24 @@
110
119
return Response.noContent().build();
111
120
}
112
121
122
+ /** Build the Gitea repository view, filling the fields that need a git read (default branch, emptiness),
123
+ * the request's external base URL (clone/html URLs) and the caller's effective permissions. */
124
+ private ApiModels.RepositoryView view(Repository repo)
125
+ {
126
+ java.nio.file.Path path = service.repositoryPath(repo);
127
+ boolean empty = browse.isEmpty(path);
128
+ String defaultBranch = browse.defaultBranch(path);
129
+ String base = uriInfo.getBaseUri().toString();
130
+ String handle = repo.ownerHandle();
131
+ String cloneUrl = base + "git/" + handle + "/" + repo.name + ".git";
132
+ String htmlUrl = base + "repos/" + handle + "/" + repo.name;
133
+ User caller = principal.orNull();
134
+ ApiModels.PermissionsView permissions = new ApiModels.PermissionsView(accessPolicy.canAdmin(caller, repo),
135
+ accessPolicy.canWrite(caller, repo), accessPolicy.canRead(caller, repo));
136
+ return ApiModels.RepositoryView.of(repo, canSeeParent(repo), empty, defaultBranch, cloneUrl, htmlUrl,
137
+ permissions);
138
+ }
139
+
113
140
/** Whether the caller may see this repo's fork parent — true only when it is a fork of a repo they can read. */
114
141
private boolean canSeeParent(Repository repo)
115
142
{
MODIFY
src/main/java/de/workaround/api/SearchApiResource.java
+1 -1
@@ -29,7 +29,7 @@
29
29
{
30
30
SearchResults results = search.search(principal.orNull(), q);
31
31
return new ApiModels.SearchView(
32
- results.repositories().stream().map(ApiModels.RepositoryView::of).toList(),
32
+ results.repositories().stream().map(ApiModels.RepositoryView::shallow).toList(),
33
33
results.persons().stream().map(ApiModels.PersonView::of).toList());
34
34
}
35
35
}
MODIFY
src/main/java/de/workaround/mcp/RepositoryTools.java
+20 -4
@@ -3,6 +3,7 @@
3
3
import java.util.List;
4
4
5
5
import de.workaround.api.ApiModels;
6
+import de.workaround.git.GitBrowseService;
6
7
import de.workaround.git.GitRepositoryService;
7
8
import de.workaround.model.Repository;
8
9
import de.workaround.model.User;
@@ -23,6 +24,9 @@
23
24
GitRepositoryService service;
24
25
25
26
@Inject
27
+ GitBrowseService browse;
28
+
29
+ @Inject
26
30
McpRepoAccess access;
27
31
28
32
@Inject
@@ -34,7 +38,7 @@
34
38
@Tool(description = "List repositories visible to the caller: all public repositories, plus your own private ones when a valid token is supplied.")
35
39
public List<ApiModels.RepositoryView> listRepositories()
36
40
{
37
- return service.listVisibleTo(principal.orNull()).stream().map(ApiModels.RepositoryView::of).toList();
41
+ return service.listVisibleTo(principal.orNull()).stream().map(this::view).toList();
38
42
}
39
43
40
44
@Tool(description = "Get a single repository by owner username and repository name.")
@@ -42,7 +46,7 @@
42
46
@ToolArg(description = "Repository name") String name)
43
47
{
44
48
Repository repo = access.requireReadable(principal.orNull(), owner, name);
45
- return ApiModels.RepositoryView.of(repo, canSeeParent(repo));
49
+ return view(repo);
46
50
}
47
51
48
52
@Tool(description = "Create a new repository owned by the authenticated user. Requires a personal access token.")
@@ -57,7 +61,7 @@
57
61
? Repository.Visibility.PRIVATE : Repository.Visibility.PUBLIC;
58
62
Repository repo = service.create(user, name, parsed,
59
63
description == null || description.isBlank() ? null : description);
60
- return ApiModels.RepositoryView.of(repo);
64
+ return view(repo);
61
65
}
62
66
63
67
@Tool(description = "Fork a repository you can read into your own namespace, copying all branches and tags. Requires a personal access token.")
@@ -68,7 +72,7 @@
68
72
User user = principal.require();
69
73
Repository source = access.requireReadable(user, owner, name);
70
74
Repository forked = service.fork(user, source);
71
- return ApiModels.RepositoryView.of(forked, canSeeParent(forked));
75
+ return view(forked);
72
76
}
73
77
74
78
private boolean canSeeParent(Repository repo)
@@ -76,6 +80,18 @@
76
80
return repo.parent != null && accessPolicy.canRead(principal.orNull(), repo.parent);
77
81
}
78
82
83
+ /** MCP has no request base URL, so clone/html URLs are left null; the git-derived fields and permissions
84
+ * are still populated so AI clients see the same shape the REST API serves. */
85
+ private ApiModels.RepositoryView view(Repository repo)
86
+ {
87
+ java.nio.file.Path path = service.repositoryPath(repo);
88
+ User caller = principal.orNull();
89
+ ApiModels.PermissionsView permissions = new ApiModels.PermissionsView(accessPolicy.canAdmin(caller, repo),
90
+ accessPolicy.canWrite(caller, repo), accessPolicy.canRead(caller, repo));
91
+ return ApiModels.RepositoryView.of(repo, canSeeParent(repo), browse.isEmpty(path),
92
+ browse.defaultBranch(path), null, null, permissions);
93
+ }
94
+
79
95
@Tool(description = "Delete a repository you own. Requires a personal access token. This is irreversible.")
80
96
@Transactional
81
97
public String deleteRepository(@ToolArg(description = "Owner username") String owner,
MODIFY
src/test/java/de/workaround/api/ApiSmokeIT.java
+4 -2
@@ -38,9 +38,11 @@
38
38
.then()
39
39
.statusCode(200)
40
40
.contentType("application/json")
41
- .body("owner", equalTo("alice"))
41
+ .body("owner.login", equalTo("alice"))
42
42
.body("name", equalTo("demo"))
43
- .body("visibility", equalTo("PUBLIC"));
43
+ .body("full_name", equalTo("alice/demo"))
44
+ .body("private", org.hamcrest.CoreMatchers.is(false))
45
+ .body("clone_url", org.hamcrest.Matchers.endsWith("/git/alice/demo.git"));
44
46
}
45
47
46
48
@Test
MODIFY
src/test/java/de/workaround/api/RepositoryApiTest.java
+16 -10
@@ -35,22 +35,27 @@
35
35
User owner = persistUser("api-repo-owner");
36
36
String token = mintToken(owner);
37
37
38
- // create
38
+ // create — response is in the Gitea repository shape
39
39
String location = given().header("Authorization", "Bearer " + token)
40
40
.contentType("application/json")
41
41
.body(Map.of("name", "widgets", "visibility", "PUBLIC", "description", "gadgets"))
42
42
.when().post("/api/v1/repos")
43
43
.then().statusCode(201)
44
44
.body("name", equalTo("widgets"))
45
- .body("owner", equalTo(owner.username))
46
- .body("visibility", equalTo("PUBLIC"))
45
+ .body("full_name", equalTo(owner.username + "/widgets"))
46
+ .body("owner.login", equalTo(owner.username))
47
+ .body("private", is(false))
47
48
.body("description", equalTo("gadgets"))
49
+ .body("default_branch", equalTo("main"))
50
+ .body("clone_url", org.hamcrest.Matchers.endsWith("/git/" + owner.username + "/widgets.git"))
51
+ .body("permissions.admin", is(true))
48
52
.extract().header("Location");
49
53
50
54
// get via Location
51
55
given().when().get(location)
52
56
.then().statusCode(200)
53
- .body("name", equalTo("widgets"));
57
+ .body("name", equalTo("widgets"))
58
+ .body("full_name", equalTo(owner.username + "/widgets"));
54
59
55
60
// list includes it (public, visible even anonymously)
56
61
given().when().get("/api/v1/repos")
@@ -144,10 +149,11 @@
144
149
given().header("Authorization", "Bearer " + token)
145
150
.when().post("/api/v1/repos/" + owner.username + "/tocopy/fork")
146
151
.then().statusCode(201)
147
- .body("owner", equalTo(forker.username))
152
+ .body("owner.login", equalTo(forker.username))
148
153
.body("name", equalTo("tocopy"))
149
- .body("parentOwner", equalTo(owner.username))
150
- .body("parentName", equalTo("tocopy"));
154
+ .body("fork", is(true))
155
+ .body("parent.owner.login", equalTo(owner.username))
156
+ .body("parent.name", equalTo("tocopy"));
151
157
}
152
158
153
159
@Test
@@ -161,7 +167,7 @@
161
167
given().header("Authorization", "Bearer " + token)
162
168
.when().post("/api/v1/repos/" + owner.username + "/flip/fork")
163
169
.then().statusCode(201)
164
- .body("parentOwner", equalTo(owner.username));
170
+ .body("parent.owner.login", equalTo(owner.username));
165
171
166
172
// owner makes the source private; the forker can no longer read it
167
173
service.changeVisibility(owner, source, Repository.Visibility.PRIVATE);
@@ -169,8 +175,8 @@
169
175
given().header("Authorization", "Bearer " + token)
170
176
.when().get("/api/v1/repos/" + forker.username + "/flip")
171
177
.then().statusCode(200)
172
- .body("parentOwner", org.hamcrest.Matchers.nullValue())
173
- .body("parentName", org.hamcrest.Matchers.nullValue());
178
+ .body("fork", is(true))
179
+ .body("parent", org.hamcrest.Matchers.nullValue());
174
180
}
175
181
176
182
@Test