✨ (api): Add Gitea labels and commit-status stubs
Changes
6 files changed, +324 -2
MODIFY
README.md
+3 -0
@@ -154,6 +154,9 @@
154
154
| POST | `/api/v1/repos/{owner}/{name}/pulls/{number}/merge` | Merge |
155
155
| GET, POST | `/api/v1/repos/{owner}/{name}/pulls/{number}/comments` | List / add line-level review comments (any reader may comment) |
156
156
| DELETE | `/api/v1/repos/{owner}/{name}/pulls/{number}/comments/{commentId}` | Delete a comment (author, repo owner, or collaborator) |
157
+| GET | `/api/v1/repos/{owner}/{name}/labels` | Labels (empty — no label model yet) |
158
+| GET | `/api/v1/repos/{owner}/{name}/commits/{ref}/status` | Combined commit status (all-clear stub — no status store yet) |
159
+| GET, POST | `/api/v1/repos/{owner}/{name}/commits/{ref}/statuses` · `/statuses/{sha}` | List statuses (empty) / post a status (echoed, not persisted; needs write) |
157
160
158
161
## MCP server
159
162
MODIFY
docs/maintainers/gitea-api.md
+8 -2
@@ -29,6 +29,8 @@
29
29
| Version probe | `VersionApiResource` | `GET /api/v1/version`; string from `gitshark.gitea-api.version` |
30
30
| Repositories | `RepositoryApiResource` | Gitea repository object incl. `owner`, `full_name`, `default_branch`, `clone_url`, `html_url`, `permissions`, merge flags |
31
31
| Pulls | `PullApiResource` | Merge requests as Gitea pull requests (list/create/get/PATCH/merge + line-review comments); domain stays `MergeRequest*` |
32
+| Labels | `LabelApiResource` | Always `[]` — no label model yet |
33
+| Statuses | `CommitStatusApiResource` | All-clear combined status + echoing `POST /statuses/{sha}`; no status store yet |
32
34
| User | `UserApiResource` | Self identity in Gitea user shape |
33
35
| Search | `SearchApiResource` | git-shark-specific (not a Gitea endpoint); returns the email-free `PersonView` and a shallow repository projection |
34
36
@@ -80,14 +82,18 @@
80
82
merge commits). List supports `?state=open|closed|all` and pagination (page
81
83
size capped at 50 so Renovate's paging terminates). The line-review comments
82
84
are git-shark's own feature, kept under `pulls/{number}/comments`.
85
+- `GET labels` → `[]` (no label model yet; Renovate skips labels when empty).
86
+- Commit-status stubs: `GET commits/{ref}/status` reports an all-clear combined
87
+ status, `GET commits/{ref}/statuses` is empty, and `POST statuses/{sha}` echoes
88
+ the posted status without persisting (no status store yet). Enough for Renovate
89
+ to treat a branch as passing and proceed; `ref` is matched greedily for slashes.
83
90
84
91
## What still needs to be implemented
85
92
86
93
- Find-by-branch `GET pulls/{base}/{head}` — deliberately skipped: Renovate finds
87
94
a branch's pull by listing and filtering client-side, and the two-segment route
88
95
would collide with `pulls/{index}` / `pulls/{index}/comments`.
89
-- `GET labels` → `[]` stub and commit-status stubs (`POST /statuses/{sha}`,
90
- `GET /commits/{ref}/statuses`) so Renovate proceeds.
96
+- A real commit-status store wired to the CI runners (replace the stubs above).
91
97
- `GET /repos/{owner}/{name}/contents/{path}` (Renovate mostly clones, so low
92
98
priority).
93
99
- Issue open/closed mapping + issue-comment REST endpoints (dependency dashboard);
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+29 -0
@@ -212,6 +212,35 @@
212
212
{
213
213
}
214
214
215
+ /**
216
+ * A Gitea commit status. git-shark has no status store yet, so {@code POST /statuses/{sha}} echoes the
217
+ * posted status back (nothing is persisted) and the list/ combined endpoints report an empty, all-clear set.
218
+ */
219
+ public record CommitStatusView(long id, String state, String context, String description,
220
+ @JsonProperty("target_url") String targetUrl)
221
+ {
222
+ }
223
+
224
+ /**
225
+ * The Gitea combined commit status. With no status store, the combined {@code state} is reported as
226
+ * {@code success} (both under the current field name and the legacy {@code worstStatus}) so a Gitea client
227
+ * treats the ref as passing and proceeds; the merge endpoint still rejects a real conflict.
228
+ */
229
+ public record CombinedStatusView(String state, @JsonProperty("worstStatus") String worstStatus, String sha,
230
+ @JsonProperty("total_count") int totalCount, List<CommitStatusView> statuses)
231
+ {
232
+ public static CombinedStatusView allClear(String sha)
233
+ {
234
+ return new CombinedStatusView("success", "success", sha, 0, List.of());
235
+ }
236
+ }
237
+
238
+ /** Gitea create-status payload posted to {@code /statuses/{sha}}. */
239
+ public record NewStatus(String state, String context, String description,
240
+ @JsonProperty("target_url") String targetUrl)
241
+ {
242
+ }
243
+
215
244
public record NewComment(String filePath, int oldLine, int newLine, String body)
216
245
{
217
246
}
ADD
src/main/java/de/workaround/api/CommitStatusApiResource.java
+88 -0
@@ -0,0 +1,88 @@
1
+package de.workaround.api;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.git.AccessPolicy;
6
+import de.workaround.git.ForbiddenOperationException;
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.Repository;
9
+import de.workaround.model.User;
10
+import jakarta.inject.Inject;
11
+import jakarta.ws.rs.Consumes;
12
+import jakarta.ws.rs.GET;
13
+import jakarta.ws.rs.NotFoundException;
14
+import jakarta.ws.rs.POST;
15
+import jakarta.ws.rs.Path;
16
+import jakarta.ws.rs.PathParam;
17
+import jakarta.ws.rs.Produces;
18
+import jakarta.ws.rs.core.MediaType;
19
+import jakarta.ws.rs.core.Response;
20
+
21
+/**
22
+ * Gitea commit-status endpoints. git-shark has no status store yet (CI runners exist but are unwired), so the
23
+ * combined/list reads report an empty, all-clear set and {@code POST /statuses/{sha}} echoes the posted status
24
+ * without persisting it — enough for Renovate to treat a branch as passing and proceed. The {@code ref} segment
25
+ * is matched greedily so slash-bearing branch names resolve. Reads follow repository visibility; posting a
26
+ * status needs a token and write access.
27
+ */
28
+@Path("/api/v1/repos/{owner}/{name}")
29
+@Produces(MediaType.APPLICATION_JSON)
30
+public class CommitStatusApiResource
31
+{
32
+ @Inject
33
+ GitRepositoryService repositories;
34
+
35
+ @Inject
36
+ AccessPolicy accessPolicy;
37
+
38
+ @Inject
39
+ ApiPrincipal principal;
40
+
41
+ @GET
42
+ @Path("commits/{ref:.+}/status")
43
+ public ApiModels.CombinedStatusView combined(@PathParam("owner") String owner, @PathParam("name") String name,
44
+ @PathParam("ref") String ref)
45
+ {
46
+ requireReadable(owner, name);
47
+ return ApiModels.CombinedStatusView.allClear(ref);
48
+ }
49
+
50
+ @GET
51
+ @Path("commits/{ref:.+}/statuses")
52
+ public List<ApiModels.CommitStatusView> list(@PathParam("owner") String owner, @PathParam("name") String name,
53
+ @PathParam("ref") String ref)
54
+ {
55
+ requireReadable(owner, name);
56
+ return List.of();
57
+ }
58
+
59
+ @POST
60
+ @Path("statuses/{sha}")
61
+ @Consumes(MediaType.APPLICATION_JSON)
62
+ public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
63
+ @PathParam("sha") String sha, ApiModels.NewStatus request)
64
+ {
65
+ User user = principal.require();
66
+ Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
67
+ if (!accessPolicy.canRead(principal.orNull(), repo))
68
+ {
69
+ throw new NotFoundException();
70
+ }
71
+ if (!accessPolicy.canWrite(user, repo))
72
+ {
73
+ throw new ForbiddenOperationException("Only the repository owner or a collaborator can post statuses");
74
+ }
75
+ ApiModels.CommitStatusView echoed = new ApiModels.CommitStatusView(0, request.state(), request.context(),
76
+ request.description(), request.targetUrl());
77
+ return Response.status(Response.Status.CREATED).entity(echoed).build();
78
+ }
79
+
80
+ private void requireReadable(String owner, String name)
81
+ {
82
+ Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
83
+ if (!accessPolicy.canRead(principal.orNull(), repo))
84
+ {
85
+ throw new NotFoundException();
86
+ }
87
+ }
88
+}
ADD
src/main/java/de/workaround/api/LabelApiResource.java
+49 -0
@@ -0,0 +1,49 @@
1
+package de.workaround.api;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.git.AccessPolicy;
6
+import de.workaround.git.GitRepositoryService;
7
+import de.workaround.model.Repository;
8
+import jakarta.inject.Inject;
9
+import jakarta.ws.rs.GET;
10
+import jakarta.ws.rs.NotFoundException;
11
+import jakarta.ws.rs.Path;
12
+import jakarta.ws.rs.PathParam;
13
+import jakarta.ws.rs.Produces;
14
+import jakarta.ws.rs.core.MediaType;
15
+
16
+/**
17
+ * The Gitea {@code GET /api/v1/repos/{owner}/{name}/labels} endpoint. git-shark has no label model yet, so
18
+ * this always returns an empty list — enough for Renovate, which skips label handling when the list is empty.
19
+ * Reads follow repository visibility (private repos hidden as 404).
20
+ */
21
+@Path("/api/v1/repos/{owner}/{name}/labels")
22
+@Produces(MediaType.APPLICATION_JSON)
23
+public class LabelApiResource
24
+{
25
+ @Inject
26
+ GitRepositoryService repositories;
27
+
28
+ @Inject
29
+ AccessPolicy accessPolicy;
30
+
31
+ @Inject
32
+ ApiPrincipal principal;
33
+
34
+ @GET
35
+ public List<Object> list(@PathParam("owner") String owner, @PathParam("name") String name)
36
+ {
37
+ requireReadable(owner, name);
38
+ return List.of();
39
+ }
40
+
41
+ private void requireReadable(String owner, String name)
42
+ {
43
+ Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
44
+ if (!accessPolicy.canRead(principal.orNull(), repo))
45
+ {
46
+ throw new NotFoundException();
47
+ }
48
+ }
49
+}
ADD
src/test/java/de/workaround/api/LabelStatusApiTest.java
+147 -0
@@ -0,0 +1,147 @@
1
+package de.workaround.api;
2
+
3
+import java.util.Map;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.http.AccessTokenService;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import io.quarkus.test.junit.QuarkusTest;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+
16
+import static io.restassured.RestAssured.given;
17
+import static org.hamcrest.CoreMatchers.equalTo;
18
+import static org.hamcrest.Matchers.hasSize;
19
+
20
+@QuarkusTest
21
+class LabelStatusApiTest
22
+{
23
+ @Inject
24
+ GitRepositoryService service;
25
+
26
+ @Inject
27
+ AccessTokenService tokenService;
28
+
29
+ @Inject
30
+ User.Repo userRepo;
31
+
32
+ @Test
33
+ void labelsAreAnEmptyList()
34
+ {
35
+ User owner = persistUser("api-labels-" + shortId());
36
+ service.create(owner, "lrepo", Repository.Visibility.PUBLIC, null);
37
+
38
+ given().when().get("/api/v1/repos/" + owner.username + "/lrepo/labels")
39
+ .then().statusCode(200)
40
+ .body("$", hasSize(0));
41
+ }
42
+
43
+ @Test
44
+ void combinedStatusIsAllClear()
45
+ {
46
+ User owner = persistUser("api-status-" + shortId());
47
+ service.create(owner, "srepo", Repository.Visibility.PUBLIC, null);
48
+
49
+ given().when().get("/api/v1/repos/" + owner.username + "/srepo/commits/main/status")
50
+ .then().statusCode(200)
51
+ .body("state", equalTo("success"))
52
+ .body("statuses", hasSize(0));
53
+ }
54
+
55
+ @Test
56
+ void combinedStatusResolvesSlashBearingRef()
57
+ {
58
+ User owner = persistUser("api-status-slash-" + shortId());
59
+ service.create(owner, "srepo", Repository.Visibility.PUBLIC, null);
60
+
61
+ given().when().get("/api/v1/repos/" + owner.username + "/srepo/commits/renovate/foo/status")
62
+ .then().statusCode(200)
63
+ .body("state", equalTo("success"));
64
+ }
65
+
66
+ @Test
67
+ void statusListIsEmpty()
68
+ {
69
+ User owner = persistUser("api-statuslist-" + shortId());
70
+ service.create(owner, "srepo", Repository.Visibility.PUBLIC, null);
71
+
72
+ given().when().get("/api/v1/repos/" + owner.username + "/srepo/commits/main/statuses")
73
+ .then().statusCode(200)
74
+ .body("$", hasSize(0));
75
+ }
76
+
77
+ @Test
78
+ void postingAStatusEchoesItBack()
79
+ {
80
+ User owner = persistUser("api-poststatus-" + shortId());
81
+ String token = tokenService.create(owner, "api-test").plaintext();
82
+ service.create(owner, "srepo", Repository.Visibility.PUBLIC, null);
83
+
84
+ given().header("Authorization", "Bearer " + token).contentType("application/json")
85
+ .body(Map.of("state", "success", "context", "ci/build", "description", "ok"))
86
+ .when().post("/api/v1/repos/" + owner.username + "/srepo/statuses/deadbeef")
87
+ .then().statusCode(201)
88
+ .body("state", equalTo("success"))
89
+ .body("context", equalTo("ci/build"));
90
+ }
91
+
92
+ @Test
93
+ void postingAStatusRequiresAuthentication()
94
+ {
95
+ User owner = persistUser("api-poststatus-noauth-" + shortId());
96
+ service.create(owner, "srepo", Repository.Visibility.PUBLIC, null);
97
+
98
+ given().contentType("application/json")
99
+ .body(Map.of("state", "success", "context", "ci/build"))
100
+ .when().post("/api/v1/repos/" + owner.username + "/srepo/statuses/deadbeef")
101
+ .then().statusCode(401);
102
+ }
103
+
104
+ @Test
105
+ void postingAStatusAsNonWriterIsForbidden()
106
+ {
107
+ User owner = persistUser("api-poststatus-owner-" + shortId());
108
+ service.create(owner, "srepo", Repository.Visibility.PUBLIC, null);
109
+ User stranger = persistUser("api-poststatus-stranger-" + shortId());
110
+ String token = tokenService.create(stranger, "api-test").plaintext();
111
+
112
+ given().header("Authorization", "Bearer " + token).contentType("application/json")
113
+ .body(Map.of("state", "success", "context", "ci/build"))
114
+ .when().post("/api/v1/repos/" + owner.username + "/srepo/statuses/deadbeef")
115
+ .then().statusCode(403);
116
+ }
117
+
118
+ @Test
119
+ void labelsOfPrivateRepoHiddenFromStrangers()
120
+ {
121
+ User owner = persistUser("api-labels-priv-" + shortId());
122
+ service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
123
+
124
+ given().when().get("/api/v1/repos/" + owner.username + "/secret/labels")
125
+ .then().statusCode(404);
126
+ }
127
+
128
+ private static String shortId()
129
+ {
130
+ return UUID.randomUUID().toString().substring(0, 8);
131
+ }
132
+
133
+ @Transactional
134
+ User persistUser(String name)
135
+ {
136
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
137
+ if (existing != null)
138
+ {
139
+ return existing;
140
+ }
141
+ User user = new User();
142
+ user.oidcSub = name;
143
+ user.username = name;
144
+ user.persist();
145
+ return user;
146
+ }
147
+}