✨ (api): Add Gitea-compatible branch endpoint
Changes
5 files changed, +177 -1
MODIFY
README.md
+1 -0
@@ -145,6 +145,7 @@
145
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
147
| GET | `/api/v1/repos/{owner}/{name}` | Repository detail in Gitea shape (`full_name`, `default_branch`, `clone_url`, `permissions`, …) |
148
+| GET | `/api/v1/repos/{owner}/{name}/branches/{branch}` | Branch tip in Gitea shape (`name`, `commit.id`, `protected`); slash-bearing names supported |
148
149
| DELETE | `/api/v1/repos/{owner}/{name}` | Delete a repository (owner only) |
149
150
| GET, POST | `/api/v1/repos/{owner}/{name}/issues` | List / create issues |
150
151
| GET, PATCH, DELETE | `/api/v1/repos/{owner}/{name}/issues/{number}` | Get / update status / delete an issue |
MODIFY
docs/maintainers/gitea-api.md
+3 -1
@@ -63,10 +63,12 @@
63
63
- `GET /api/v1/repos` and `GET /api/v1/repos/{owner}/{name}` — Gitea repository
64
64
objects, including `default_branch` (live git read), `clone_url`/`html_url`
65
65
(from the request base URL), `permissions`, `fork`/`parent`, and merge flags.
66
+- `GET /api/v1/repos/{owner}/{name}/branches/{branch}` — branch object
67
+ (`name`, `commit.id`, `protected`); the branch segment is matched greedily so
68
+ slash-bearing names resolve, and only real branch refs count (tag/SHA → 404).
66
69
67
70
## What still needs to be implemented
68
71
69
-- `GET /api/v1/repos/{owner}/{name}/branches/{branch}` — branch object.
70
72
- `pulls` resource: rename `merge-requests` → `pulls`, reshape to Gitea pull
71
73
requests, add find-by-branch (`GET pulls/{base}/{head}`), `PATCH pulls/{index}`
72
74
(title/body/state), keep create/get/list/merge.
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+11 -0
@@ -142,6 +142,17 @@
142
142
}
143
143
144
144
/**
145
+ * A branch in the Gitea contract. Clients (Renovate) read {@code commit.id} as the branch tip SHA.
146
+ * git-shark has no branch protection, so {@code protected} is always false.
147
+ */
148
+ public record BranchView(String name, CommitRef commit, @JsonProperty("protected") boolean isProtected)
149
+ {
150
+ public record CommitRef(String id)
151
+ {
152
+ }
153
+ }
154
+
155
+ /**
145
156
* A person in a listing where the caller's read access is unknown (search is open to anonymous callers).
146
157
* Deliberately omits {@code email}: unlike the self-scoped {@code /api/v1/user}, a search response would
147
158
* otherwise disclose every matched user's address to unauthenticated callers.
ADD
src/main/java/de/workaround/api/BranchApiResource.java
+57 -0
@@ -0,0 +1,57 @@
1
+package de.workaround.api;
2
+
3
+import de.workaround.git.AccessPolicy;
4
+import de.workaround.git.GitBrowseService;
5
+import de.workaround.git.GitRepositoryService;
6
+import de.workaround.model.Repository;
7
+import jakarta.inject.Inject;
8
+import jakarta.ws.rs.GET;
9
+import jakarta.ws.rs.NotFoundException;
10
+import jakarta.ws.rs.Path;
11
+import jakarta.ws.rs.PathParam;
12
+import jakarta.ws.rs.Produces;
13
+import jakarta.ws.rs.core.MediaType;
14
+
15
+/**
16
+ * The Gitea {@code GET /api/v1/repos/{owner}/{name}/branches/{branch}} endpoint. Renovate reads the returned
17
+ * {@code commit.id} as the branch tip SHA. The branch segment is matched greedily so slash-bearing names
18
+ * (e.g. {@code renovate/foo}) resolve; only real branch refs count, so a tag or raw SHA yields 404. Reads
19
+ * follow repository visibility (private repos hidden as 404).
20
+ */
21
+@Path("/api/v1/repos/{owner}/{name}/branches")
22
+@Produces(MediaType.APPLICATION_JSON)
23
+public class BranchApiResource
24
+{
25
+ @Inject
26
+ GitRepositoryService repositories;
27
+
28
+ @Inject
29
+ GitBrowseService browse;
30
+
31
+ @Inject
32
+ AccessPolicy accessPolicy;
33
+
34
+ @Inject
35
+ ApiPrincipal principal;
36
+
37
+ @GET
38
+ @Path("{branch:.+}")
39
+ public ApiModels.BranchView get(@PathParam("owner") String owner, @PathParam("name") String name,
40
+ @PathParam("branch") String branch)
41
+ {
42
+ Repository repo = requireReadable(owner, name);
43
+ GitBrowseService.CommitInfo tip = browse.commit(repositories.repositoryPath(repo), "refs/heads/" + branch)
44
+ .orElseThrow(NotFoundException::new);
45
+ return new ApiModels.BranchView(branch, new ApiModels.BranchView.CommitRef(tip.id()), false);
46
+ }
47
+
48
+ private Repository requireReadable(String owner, String name)
49
+ {
50
+ Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
51
+ if (!accessPolicy.canRead(principal.orNull(), repo))
52
+ {
53
+ throw new NotFoundException();
54
+ }
55
+ return repo;
56
+ }
57
+}
ADD
src/test/java/de/workaround/api/BranchApiTest.java
+105 -0
@@ -0,0 +1,105 @@
1
+package de.workaround.api;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.util.Map;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Test;
8
+
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.git.GitTestSeeder;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import jakarta.inject.Inject;
15
+import jakarta.transaction.Transactional;
16
+
17
+import static io.restassured.RestAssured.given;
18
+import static org.hamcrest.CoreMatchers.equalTo;
19
+import static org.hamcrest.CoreMatchers.is;
20
+import static org.hamcrest.text.MatchesPattern.matchesPattern;
21
+
22
+@QuarkusTest
23
+class BranchApiTest
24
+{
25
+ @Inject
26
+ GitRepositoryService service;
27
+
28
+ @Inject
29
+ User.Repo userRepo;
30
+
31
+ @Test
32
+ void getBranchReturnsGiteaShapeWithCommitId() throws Exception
33
+ {
34
+ User owner = persistUser("api-branch-" + shortId());
35
+ Repository repo = service.create(owner, "brepo", Repository.Visibility.PUBLIC, null);
36
+ GitTestSeeder.seed(service.repositoryPath(repo),
37
+ Map.of("f.txt", "x\n".getBytes(StandardCharsets.UTF_8)));
38
+
39
+ given().when().get("/api/v1/repos/" + owner.username + "/brepo/branches/main")
40
+ .then().statusCode(200)
41
+ .body("name", equalTo("main"))
42
+ .body("commit.id", matchesPattern("[0-9a-f]{40}"))
43
+ .body("protected", is(false));
44
+ }
45
+
46
+ @Test
47
+ void branchNameWithSlashResolves() throws Exception
48
+ {
49
+ User owner = persistUser("api-branch-slash-" + shortId());
50
+ Repository repo = service.create(owner, "brepo", Repository.Visibility.PUBLIC, null);
51
+ GitTestSeeder.seed(service.repositoryPath(repo),
52
+ Map.of("f.txt", "x\n".getBytes(StandardCharsets.UTF_8)));
53
+ GitTestSeeder.seedBranch(service.repositoryPath(repo), "feat/x",
54
+ Map.of("g.txt", "y\n".getBytes(StandardCharsets.UTF_8)));
55
+
56
+ given().when().get("/api/v1/repos/" + owner.username + "/brepo/branches/feat/x")
57
+ .then().statusCode(200)
58
+ .body("name", equalTo("feat/x"))
59
+ .body("commit.id", matchesPattern("[0-9a-f]{40}"));
60
+ }
61
+
62
+ @Test
63
+ void unknownBranchIs404() throws Exception
64
+ {
65
+ User owner = persistUser("api-branch-missing-" + shortId());
66
+ Repository repo = service.create(owner, "brepo", Repository.Visibility.PUBLIC, null);
67
+ GitTestSeeder.seed(service.repositoryPath(repo),
68
+ Map.of("f.txt", "x\n".getBytes(StandardCharsets.UTF_8)));
69
+
70
+ given().when().get("/api/v1/repos/" + owner.username + "/brepo/branches/nope")
71
+ .then().statusCode(404);
72
+ }
73
+
74
+ @Test
75
+ void branchOfPrivateRepoIsHiddenFromStrangers() throws Exception
76
+ {
77
+ User owner = persistUser("api-branch-priv-" + shortId());
78
+ Repository repo = service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
79
+ GitTestSeeder.seed(service.repositoryPath(repo),
80
+ Map.of("f.txt", "x\n".getBytes(StandardCharsets.UTF_8)));
81
+
82
+ given().when().get("/api/v1/repos/" + owner.username + "/secret/branches/main")
83
+ .then().statusCode(404);
84
+ }
85
+
86
+ private static String shortId()
87
+ {
88
+ return UUID.randomUUID().toString().substring(0, 8);
89
+ }
90
+
91
+ @Transactional
92
+ User persistUser(String name)
93
+ {
94
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
95
+ if (existing != null)
96
+ {
97
+ return existing;
98
+ }
99
+ User user = new User();
100
+ user.oidcSub = name;
101
+ user.username = name;
102
+ user.persist();
103
+ return user;
104
+ }
105
+}