gitshark

Clone repository

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

← Commits

✨ (api): Add Gitea labels and commit-status stubs

5ff58c5b3cee5ffaf91c0b6f19ffccdf749fcd6e · Michael Hainz · 2026-07-21T11:49:10Z

Changes

6 files changed, +324 -2

MODIFY README.md +3 -0
diff --git a/README.md b/README.md
index 48534a9..5803e01 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/maintainers/gitea-api.md b/docs/maintainers/gitea-api.md
index 2e3c416..1c16ffd 100644
--- a/docs/maintainers/gitea-api.md
+++ b/docs/maintainers/gitea-api.md
@@ -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
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index 3cec6d3..5f574ee 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -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
diff --git a/src/main/java/de/workaround/api/CommitStatusApiResource.java b/src/main/java/de/workaround/api/CommitStatusApiResource.java
new file mode 100644
index 0000000..a63514a
--- /dev/null
+++ b/src/main/java/de/workaround/api/CommitStatusApiResource.java
@@ -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
diff --git a/src/main/java/de/workaround/api/LabelApiResource.java b/src/main/java/de/workaround/api/LabelApiResource.java
new file mode 100644
index 0000000..883c54f
--- /dev/null
+++ b/src/main/java/de/workaround/api/LabelApiResource.java
@@ -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
diff --git a/src/test/java/de/workaround/api/LabelStatusApiTest.java b/src/test/java/de/workaround/api/LabelStatusApiTest.java
new file mode 100644
index 0000000..2ea1746
--- /dev/null
+++ b/src/test/java/de/workaround/api/LabelStatusApiTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog