✨ (api): Migrate merge-requests to a Gitea pulls resource
Changes
10 files changed, +447 -230
MODIFY
README.md
+6 -7
@@ -26,7 +26,7 @@
26
26
- Per-repository issues: title, optional description (rendered as Markdown, XSS-safe), per-repo sequential number (`#1`, `#2`, …), an author, and an optional assignee (any local user, set by username; blank clears it); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page; title and description can be edited afterwards via an "Edit issue" page. Issue pages are addressed by number (`…/issues/1`); old UUID URLs redirect permanently to the number form
27
27
- Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
28
28
- Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
29
-- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), an author, and an optional assignee and reviewer (any local user, set by username from a GitHub-style picker that suggests the repo owner, its collaborators, and the repository's top commit authors; blank clears); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches. Merge-request pages are addressed by number (`…/merge-requests/1`, matching the API and the issue URL scheme); old UUID URLs redirect permanently to the number form
29
+- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), an author, and an optional assignee and reviewer (any local user, set by username from a GitHub-style picker that suggests the repo owner, its collaborators, and the repository's top commit authors; blank clears); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches. Merge-request pages are addressed by number (`…/merge-requests/1`, matching the issue URL scheme; the REST API exposes the same objects as Gitea pull requests under `…/pulls/1`); old UUID URLs redirect permanently to the number form
30
30
- Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and left sidebar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
31
31
- Dashboard notifications: the signed-in home page (`/`) surfaces the open issues and merge requests you are involved in — ones you authored, are assigned to, or (for merge requests) are asked to review — each linking straight to the item and labelled with its repository; items in a repository you can no longer read never appear. Built on a pluggable `NotificationSource` aggregation, so future item types can contribute without touching the dashboard
32
32
- The merge request detail page renders the live diff of the source branch relative to the merge base with the target (three-dot diff), file by file with per-line add/delete coloring and a changed-files / +additions / −deletions summary — always computed live from git, never duplicated into the database
@@ -149,12 +149,11 @@
149
149
| DELETE | `/api/v1/repos/{owner}/{name}` | Delete a repository (owner only) |
150
150
| GET, POST | `/api/v1/repos/{owner}/{name}/issues` | List / create issues |
151
151
| GET, PATCH, DELETE | `/api/v1/repos/{owner}/{name}/issues/{number}` | Get / update status / delete an issue |
152
-| GET, POST | `/api/v1/repos/{owner}/{name}/merge-requests` | List / create merge requests |
153
-| GET | `/api/v1/repos/{owner}/{name}/merge-requests/{number}` | Merge request detail |
154
-| POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/merge` | Merge |
155
-| POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/close` | Close |
156
-| GET, POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments` | List / add line-level review comments (any reader may comment) |
157
-| DELETE | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments/{commentId}` | Delete a comment (author, repo owner, or collaborator) |
152
+| GET, POST | `/api/v1/repos/{owner}/{name}/pulls` | List (Gitea pull requests; `?state=open\|closed\|all`, paginated) / create (`{title, body, head, base}`) |
153
+| GET, PATCH | `/api/v1/repos/{owner}/{name}/pulls/{number}` | Pull detail / edit `title`/`body`/`state` (`state:"closed"` closes, `"open"` reopens) |
154
+| POST | `/api/v1/repos/{owner}/{name}/pulls/{number}/merge` | Merge |
155
+| GET, POST | `/api/v1/repos/{owner}/{name}/pulls/{number}/comments` | List / add line-level review comments (any reader may comment) |
156
+| DELETE | `/api/v1/repos/{owner}/{name}/pulls/{number}/comments/{commentId}` | Delete a comment (author, repo owner, or collaborator) |
158
157
159
158
## MCP server
160
159
MODIFY
docs/maintainers/gitea-api.md
+17 -3
@@ -28,6 +28,7 @@
28
28
| Surrogate ids | `GiteaIds` | Folds a `UUID` PK into a stable non-negative `long` for Gitea's int64 `id` |
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
+| Pulls | `PullApiResource` | Merge requests as Gitea pull requests (list/create/get/PATCH/merge + line-review comments); domain stays `MergeRequest*` |
31
32
| User | `UserApiResource` | Self identity in Gitea user shape |
32
33
| Search | `SearchApiResource` | git-shark-specific (not a Gitea endpoint); returns the email-free `PersonView` and a shallow repository projection |
33
34
@@ -50,6 +51,10 @@
50
51
are always false (no archive feature; push-mirrors are outbound, not incoming
51
52
mirrors), and the `allow_*` merge flags advertise merge commits only, matching
52
53
the one merge strategy the merge service implements.
54
+- **`mergeable` is a placeholder** = "the pull is open", not a real conflict
55
+ check. Computing true mergeability needs a live trial-merge per pull; Renovate
56
+ only needs a hint, and the actual `POST {number}/merge` still rejects a real
57
+ conflict, so the cheap approximation is safe.
53
58
- **PII:** the self-scoped `UserView` carries `email`; the search `PersonView`
54
59
omits it, because search is anonymous and would otherwise disclose every
55
60
matched user's address.
@@ -66,12 +71,21 @@
66
71
- `GET /api/v1/repos/{owner}/{name}/branches/{branch}` — branch object
67
72
(`name`, `commit.id`, `protected`); the branch segment is matched greedily so
68
73
slash-bearing names resolve, and only real branch refs count (tag/SHA → 404).
74
+- `pulls` resource (`/api/v1/repos/{owner}/{name}/pulls`) — merge requests
75
+ projected as Gitea pull requests (`number`/index, `id`, `title`, `body`,
76
+ `state` open/closed + `merged`, `head`/`base` refs, `mergeable`, email-free
77
+ `user`/`assignee`, empty `labels`). Create takes `{title, body, head, base}`;
78
+ `PATCH {number}` edits `title`/`body` and closes (`state:"closed"`) or reopens
79
+ (`state:"open"`); `POST {number}/merge` merges (strategy body ignored — only
80
+ merge commits). List supports `?state=open|closed|all` and pagination (page
81
+ size capped at 50 so Renovate's paging terminates). The line-review comments
82
+ are git-shark's own feature, kept under `pulls/{number}/comments`.
69
83
70
84
## What still needs to be implemented
71
85
72
-- `pulls` resource: rename `merge-requests` → `pulls`, reshape to Gitea pull
73
- requests, add find-by-branch (`GET pulls/{base}/{head}`), `PATCH pulls/{index}`
74
- (title/body/state), keep create/get/list/merge.
86
+- Find-by-branch `GET pulls/{base}/{head}` — deliberately skipped: Renovate finds
87
+ a branch's pull by listing and filtering client-side, and the two-segment route
88
+ would collide with `pulls/{index}` / `pulls/{index}/comments`.
75
89
- `GET labels` → `[]` stub and commit-status stubs (`POST /statuses/{sha}`,
76
90
`GET /commits/{ref}/statuses`) so Renovate proceeds.
77
91
- `GET /repos/{owner}/{name}/contents/{path}` (Renovate mostly clones, so low
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+32 -8
@@ -91,15 +91,33 @@
91
91
}
92
92
}
93
93
94
- public record MergeRequestView(int number, String title, String description, String sourceBranch,
95
- String targetBranch, MergeRequest.Status status, String author, String assignee, String reviewer,
96
- Instant createdAt, Instant mergedAt)
94
+ /**
95
+ * A merge request projected as a Gitea pull request. State maps {@code OPEN→open} and
96
+ * {@code MERGED/CLOSED→closed}, with a separate {@code merged} flag; {@code head}/{@code base} carry the
97
+ * source/target branches. {@code user}/{@code assignee} use the email-free {@link PersonView} because a
98
+ * public repository's pulls are world-readable. git-shark stores no update or close timestamp, so
99
+ * {@code updated_at} mirrors {@code created_at} and {@code closed_at} falls back to it for closed pulls.
100
+ */
101
+ public record PullView(long id, int number, String title, String body, String state, boolean merged,
102
+ BranchRef base, BranchRef head, PersonView user, PersonView assignee, List<Object> labels, boolean mergeable,
103
+ @JsonProperty("created_at") Instant createdAt, @JsonProperty("updated_at") Instant updatedAt,
104
+ @JsonProperty("closed_at") Instant closedAt, @JsonProperty("merged_at") Instant mergedAt)
97
105
{
98
- public static MergeRequestView of(MergeRequest mr)
106
+ public record BranchRef(String ref, String label)
99
107
{
100
- return new MergeRequestView(mr.number, mr.title, mr.description, mr.sourceBranch, mr.targetBranch,
101
- mr.status, mr.author.username, mr.assignee == null ? null : mr.assignee.username,
102
- mr.reviewer == null ? null : mr.reviewer.username, mr.createdAt, mr.mergedAt);
108
+ }
109
+
110
+ public static PullView of(MergeRequest mr)
111
+ {
112
+ boolean open = mr.status == MergeRequest.Status.OPEN;
113
+ boolean merged = mr.status == MergeRequest.Status.MERGED;
114
+ String owner = mr.repository.ownerHandle();
115
+ BranchRef base = new BranchRef(mr.targetBranch, owner + ":" + mr.targetBranch);
116
+ BranchRef head = new BranchRef(mr.sourceBranch, owner + ":" + mr.sourceBranch);
117
+ Instant closedAt = open ? null : mr.mergedAt != null ? mr.mergedAt : mr.createdAt;
118
+ return new PullView(GiteaIds.of(mr.id), mr.number, mr.title, mr.description, open ? "open" : "closed",
119
+ merged, base, head, PersonView.of(mr.author), mr.assignee == null ? null : PersonView.of(mr.assignee),
120
+ List.of(), open, mr.createdAt, mr.createdAt, closedAt, mr.mergedAt);
103
121
}
104
122
}
105
123
@@ -184,7 +202,13 @@
184
202
{
185
203
}
186
204
187
- public record NewMergeRequest(String title, String description, String sourceBranch, String targetBranch)
205
+ /** Gitea pull-creation payload: {@code head}/{@code base} are branch names, {@code body} the description. */
206
+ public record NewPull(String title, String body, String head, String base)
207
+ {
208
+ }
209
+
210
+ /** Gitea pull edit: any null field is left unchanged; {@code state} is {@code open} or {@code closed}. */
211
+ public record PullEdit(String title, String body, String state)
188
212
{
189
213
}
190
214
DELETE
src/main/java/de/workaround/api/MergeRequestApiResource.java
+0 -177
@@ -1,177 +0,0 @@
1
-package de.workaround.api;
2
-
3
-import java.util.List;
4
-import java.util.UUID;
5
-
6
-import de.workaround.git.AccessPolicy;
7
-import de.workaround.git.GitRepositoryService;
8
-import de.workaround.git.MergeRequestCommentService;
9
-import de.workaround.git.MergeRequestService;
10
-import de.workaround.model.MergeRequest;
11
-import de.workaround.model.MergeRequestComment;
12
-import de.workaround.model.Repository;
13
-import de.workaround.model.User;
14
-import jakarta.inject.Inject;
15
-import jakarta.transaction.Transactional;
16
-import jakarta.ws.rs.Consumes;
17
-import jakarta.ws.rs.DELETE;
18
-import jakarta.ws.rs.GET;
19
-import jakarta.ws.rs.NotFoundException;
20
-import jakarta.ws.rs.POST;
21
-import jakarta.ws.rs.Path;
22
-import jakarta.ws.rs.PathParam;
23
-import jakarta.ws.rs.Produces;
24
-import jakarta.ws.rs.core.MediaType;
25
-import jakarta.ws.rs.core.Response;
26
-
27
-/**
28
- * JSON REST access to a repository's merge requests under {@code /api/v1/repos/{owner}/{name}/merge-requests}.
29
- * Merge requests are addressed by their per-repository number. Listing/reading follow repository read-visibility;
30
- * creating, merging and closing require a token and repository ownership (enforced by {@link MergeRequestService}).
31
- */
32
-@Path("/api/v1/repos/{owner}/{name}/merge-requests")
33
-@Produces(MediaType.APPLICATION_JSON)
34
-public class MergeRequestApiResource
35
-{
36
- @Inject
37
- GitRepositoryService repositories;
38
-
39
- @Inject
40
- MergeRequestService mergeRequests;
41
-
42
- @Inject
43
- MergeRequestCommentService comments;
44
-
45
- @Inject
46
- MergeRequestComment.Repo commentRepo;
47
-
48
- @Inject
49
- AccessPolicy accessPolicy;
50
-
51
- @Inject
52
- ApiPrincipal principal;
53
-
54
- @GET
55
- public List<ApiModels.MergeRequestView> list(@PathParam("owner") String owner, @PathParam("name") String name)
56
- {
57
- Repository repo = requireReadable(owner, name);
58
- return mergeRequests.list(repo).stream().map(ApiModels.MergeRequestView::of).toList();
59
- }
60
-
61
- @POST
62
- @Consumes(MediaType.APPLICATION_JSON)
63
- public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
64
- ApiModels.NewMergeRequest request)
65
- {
66
- User user = principal.require();
67
- Repository repo = requireReadable(owner, name);
68
- MergeRequest mr = mergeRequests.create(user, repo, request.title(), request.description(),
69
- request.sourceBranch(), request.targetBranch());
70
- return Response.status(Response.Status.CREATED)
71
- .entity(ApiModels.MergeRequestView.of(mr)).build();
72
- }
73
-
74
- @GET
75
- @Path("{number}")
76
- public ApiModels.MergeRequestView get(@PathParam("owner") String owner, @PathParam("name") String name,
77
- @PathParam("number") int number)
78
- {
79
- Repository repo = requireReadable(owner, name);
80
- return ApiModels.MergeRequestView.of(require(repo, number));
81
- }
82
-
83
- @POST
84
- @Path("{number}/merge")
85
- @Transactional
86
- public ApiModels.MergeRequestView merge(@PathParam("owner") String owner, @PathParam("name") String name,
87
- @PathParam("number") int number)
88
- {
89
- User user = principal.require();
90
- Repository repo = requireReadable(owner, name);
91
- MergeRequest mr = require(repo, number);
92
- mergeRequests.merge(user, mr);
93
- return ApiModels.MergeRequestView.of(require(repo, number));
94
- }
95
-
96
- @POST
97
- @Path("{number}/close")
98
- @Transactional
99
- public ApiModels.MergeRequestView close(@PathParam("owner") String owner, @PathParam("name") String name,
100
- @PathParam("number") int number)
101
- {
102
- User user = principal.require();
103
- Repository repo = requireReadable(owner, name);
104
- MergeRequest mr = require(repo, number);
105
- mergeRequests.close(user, mr);
106
- return ApiModels.MergeRequestView.of(require(repo, number));
107
- }
108
-
109
- @GET
110
- @Path("{number}/comments")
111
- public List<ApiModels.CommentView> listComments(@PathParam("owner") String owner,
112
- @PathParam("name") String name, @PathParam("number") int number)
113
- {
114
- Repository repo = requireReadable(owner, name);
115
- MergeRequest mr = require(repo, number);
116
- return comments.list(mr).stream().map(ApiModels.CommentView::of).toList();
117
- }
118
-
119
- @POST
120
- @Path("{number}/comments")
121
- @Consumes(MediaType.APPLICATION_JSON)
122
- public Response addComment(@PathParam("owner") String owner, @PathParam("name") String name,
123
- @PathParam("number") int number, ApiModels.NewComment request)
124
- {
125
- User user = principal.require();
126
- Repository repo = requireReadable(owner, name);
127
- MergeRequest mr = require(repo, number);
128
- MergeRequestComment comment = comments.add(user, mr, request.filePath(), request.oldLine(),
129
- request.newLine(), request.body());
130
- return Response.status(Response.Status.CREATED)
131
- .entity(ApiModels.CommentView.of(comment)).build();
132
- }
133
-
134
- @DELETE
135
- @Path("{number}/comments/{commentId}")
136
- public Response deleteComment(@PathParam("owner") String owner,
137
- @PathParam("name") String name, @PathParam("number") int number, @PathParam("commentId") String commentId)
138
- {
139
- User user = principal.require();
140
- Repository repo = requireReadable(owner, name);
141
- MergeRequest mr = require(repo, number);
142
- MergeRequestComment comment = commentRepo.findById(parseId(commentId));
143
- if (comment == null || !comment.mergeRequest.id.equals(mr.id))
144
- {
145
- throw new NotFoundException();
146
- }
147
- comments.delete(user, comment);
148
- return Response.noContent().build();
149
- }
150
-
151
- private static UUID parseId(String id)
152
- {
153
- try
154
- {
155
- return UUID.fromString(id);
156
- }
157
- catch (IllegalArgumentException e)
158
- {
159
- throw new NotFoundException();
160
- }
161
- }
162
-
163
- private MergeRequest require(Repository repo, int number)
164
- {
165
- return mergeRequests.find(repo, number).orElseThrow(NotFoundException::new);
166
- }
167
-
168
- private Repository requireReadable(String owner, String name)
169
- {
170
- Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
171
- if (!accessPolicy.canRead(principal.orNull(), repo))
172
- {
173
- throw new NotFoundException();
174
- }
175
- return repo;
176
- }
177
-}
ADD
src/main/java/de/workaround/api/PullApiResource.java
+217 -0
@@ -0,0 +1,217 @@
1
+package de.workaround.api;
2
+
3
+import java.util.List;
4
+import java.util.UUID;
5
+
6
+import de.workaround.git.AccessPolicy;
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.git.MergeRequestCommentService;
9
+import de.workaround.git.MergeRequestService;
10
+import de.workaround.model.MergeRequest;
11
+import de.workaround.model.MergeRequestComment;
12
+import de.workaround.model.Repository;
13
+import de.workaround.model.User;
14
+import jakarta.inject.Inject;
15
+import jakarta.transaction.Transactional;
16
+import jakarta.ws.rs.Consumes;
17
+import jakarta.ws.rs.DELETE;
18
+import jakarta.ws.rs.DefaultValue;
19
+import jakarta.ws.rs.GET;
20
+import jakarta.ws.rs.NotFoundException;
21
+import jakarta.ws.rs.PATCH;
22
+import jakarta.ws.rs.POST;
23
+import jakarta.ws.rs.Path;
24
+import jakarta.ws.rs.PathParam;
25
+import jakarta.ws.rs.Produces;
26
+import jakarta.ws.rs.QueryParam;
27
+import jakarta.ws.rs.core.MediaType;
28
+import jakarta.ws.rs.core.Response;
29
+
30
+/**
31
+ * Gitea-compatible pull requests under {@code /api/v1/repos/{owner}/{name}/pulls}. Internally these are
32
+ * git-shark merge requests; only the wire speaks Gitea's "pull request". A pull is addressed by its
33
+ * per-repository {@code number} (Gitea's {@code index}). Listing/reading follow repository read-visibility;
34
+ * creating, editing, merging and closing require a token and write access (enforced by {@link MergeRequestService}).
35
+ * The line-level review comments are a git-shark feature (not Gitea PR review comments) kept under
36
+ * {@code pulls/{number}/comments}.
37
+ */
38
+@Path("/api/v1/repos/{owner}/{name}/pulls")
39
+@Produces(MediaType.APPLICATION_JSON)
40
+public class PullApiResource
41
+{
42
+ /** Renovate pages until a request returns fewer than a full page; cap the page size so paging terminates. */
43
+ private static final int MAX_PAGE_SIZE = 50;
44
+
45
+ @Inject
46
+ GitRepositoryService repositories;
47
+
48
+ @Inject
49
+ MergeRequestService mergeRequests;
50
+
51
+ @Inject
52
+ MergeRequestCommentService comments;
53
+
54
+ @Inject
55
+ MergeRequestComment.Repo commentRepo;
56
+
57
+ @Inject
58
+ AccessPolicy accessPolicy;
59
+
60
+ @Inject
61
+ ApiPrincipal principal;
62
+
63
+ @GET
64
+ public List<ApiModels.PullView> list(@PathParam("owner") String owner, @PathParam("name") String name,
65
+ @QueryParam("state") @DefaultValue("open") String state, @QueryParam("page") @DefaultValue("1") int page,
66
+ @QueryParam("limit") @DefaultValue("50") int limit)
67
+ {
68
+ Repository repo = requireReadable(owner, name);
69
+ List<ApiModels.PullView> all = mergeRequests.list(repo).stream()
70
+ .filter(mr -> matchesState(mr, state)).map(ApiModels.PullView::of).toList();
71
+ int size = Math.min(Math.max(limit, 1), MAX_PAGE_SIZE);
72
+ int from = Math.max(page - 1, 0) * size;
73
+ if (from >= all.size())
74
+ {
75
+ return List.of();
76
+ }
77
+ return all.subList(from, Math.min(from + size, all.size()));
78
+ }
79
+
80
+ @POST
81
+ @Consumes(MediaType.APPLICATION_JSON)
82
+ public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
83
+ ApiModels.NewPull request)
84
+ {
85
+ User user = principal.require();
86
+ Repository repo = requireReadable(owner, name);
87
+ MergeRequest mr = mergeRequests.create(user, repo, request.title(), request.body(),
88
+ request.head(), request.base());
89
+ return Response.status(Response.Status.CREATED).entity(ApiModels.PullView.of(mr)).build();
90
+ }
91
+
92
+ @GET
93
+ @Path("{number}")
94
+ public ApiModels.PullView get(@PathParam("owner") String owner, @PathParam("name") String name,
95
+ @PathParam("number") int number)
96
+ {
97
+ Repository repo = requireReadable(owner, name);
98
+ return ApiModels.PullView.of(require(repo, number));
99
+ }
100
+
101
+ @PATCH
102
+ @Path("{number}")
103
+ @Consumes(MediaType.APPLICATION_JSON)
104
+ @Transactional
105
+ public ApiModels.PullView update(@PathParam("owner") String owner, @PathParam("name") String name,
106
+ @PathParam("number") int number, ApiModels.PullEdit request)
107
+ {
108
+ User user = principal.require();
109
+ Repository repo = requireReadable(owner, name);
110
+ MergeRequest mr = require(repo, number);
111
+ if (request.title() != null || request.body() != null)
112
+ {
113
+ mergeRequests.update(user, mr, request.title(), request.body());
114
+ }
115
+ if ("closed".equalsIgnoreCase(request.state()))
116
+ {
117
+ mergeRequests.close(user, mr);
118
+ }
119
+ else if ("open".equalsIgnoreCase(request.state()))
120
+ {
121
+ mergeRequests.reopen(user, mr);
122
+ }
123
+ return ApiModels.PullView.of(require(repo, number));
124
+ }
125
+
126
+ @POST
127
+ @Path("{number}/merge")
128
+ @Transactional
129
+ public ApiModels.PullView merge(@PathParam("owner") String owner, @PathParam("name") String name,
130
+ @PathParam("number") int number)
131
+ {
132
+ User user = principal.require();
133
+ Repository repo = requireReadable(owner, name);
134
+ MergeRequest mr = require(repo, number);
135
+ mergeRequests.merge(user, mr);
136
+ return ApiModels.PullView.of(require(repo, number));
137
+ }
138
+
139
+ @GET
140
+ @Path("{number}/comments")
141
+ public List<ApiModels.CommentView> listComments(@PathParam("owner") String owner,
142
+ @PathParam("name") String name, @PathParam("number") int number)
143
+ {
144
+ Repository repo = requireReadable(owner, name);
145
+ MergeRequest mr = require(repo, number);
146
+ return comments.list(mr).stream().map(ApiModels.CommentView::of).toList();
147
+ }
148
+
149
+ @POST
150
+ @Path("{number}/comments")
151
+ @Consumes(MediaType.APPLICATION_JSON)
152
+ public Response addComment(@PathParam("owner") String owner, @PathParam("name") String name,
153
+ @PathParam("number") int number, ApiModels.NewComment request)
154
+ {
155
+ User user = principal.require();
156
+ Repository repo = requireReadable(owner, name);
157
+ MergeRequest mr = require(repo, number);
158
+ MergeRequestComment comment = comments.add(user, mr, request.filePath(), request.oldLine(),
159
+ request.newLine(), request.body());
160
+ return Response.status(Response.Status.CREATED)
161
+ .entity(ApiModels.CommentView.of(comment)).build();
162
+ }
163
+
164
+ @DELETE
165
+ @Path("{number}/comments/{commentId}")
166
+ public Response deleteComment(@PathParam("owner") String owner,
167
+ @PathParam("name") String name, @PathParam("number") int number, @PathParam("commentId") String commentId)
168
+ {
169
+ User user = principal.require();
170
+ Repository repo = requireReadable(owner, name);
171
+ MergeRequest mr = require(repo, number);
172
+ MergeRequestComment comment = commentRepo.findById(parseId(commentId));
173
+ if (comment == null || !comment.mergeRequest.id.equals(mr.id))
174
+ {
175
+ throw new NotFoundException();
176
+ }
177
+ comments.delete(user, comment);
178
+ return Response.noContent().build();
179
+ }
180
+
181
+ private static boolean matchesState(MergeRequest mr, String state)
182
+ {
183
+ if (state == null || "all".equalsIgnoreCase(state))
184
+ {
185
+ return true;
186
+ }
187
+ boolean open = mr.status == MergeRequest.Status.OPEN;
188
+ return "closed".equalsIgnoreCase(state) ? !open : open;
189
+ }
190
+
191
+ private static UUID parseId(String id)
192
+ {
193
+ try
194
+ {
195
+ return UUID.fromString(id);
196
+ }
197
+ catch (IllegalArgumentException e)
198
+ {
199
+ throw new NotFoundException();
200
+ }
201
+ }
202
+
203
+ private MergeRequest require(Repository repo, int number)
204
+ {
205
+ return mergeRequests.find(repo, number).orElseThrow(NotFoundException::new);
206
+ }
207
+
208
+ private Repository requireReadable(String owner, String name)
209
+ {
210
+ Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
211
+ if (!accessPolicy.canRead(principal.orNull(), repo))
212
+ {
213
+ throw new NotFoundException();
214
+ }
215
+ return repo;
216
+ }
217
+}
MODIFY
src/main/java/de/workaround/git/MergeRequestService.java
+40 -0
@@ -201,6 +201,46 @@
201
201
}
202
202
}
203
203
204
+ /** Reopens a previously CLOSED merge request. A MERGED request cannot be reopened. */
205
+ @Transactional
206
+ public void reopen(User actor, MergeRequest mr)
207
+ {
208
+ requireWrite(actor, mr.repository);
209
+ MergeRequest managed = mergeRequests.findById(mr.id);
210
+ if (managed != null && managed.status == MergeRequest.Status.CLOSED)
211
+ {
212
+ managed.status = MergeRequest.Status.OPEN;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Updates a merge request's title and/or description; a null field is left unchanged. A blank title is
218
+ * rejected. Requires write access.
219
+ */
220
+ @Transactional
221
+ public void update(User actor, MergeRequest mr, String title, String description)
222
+ {
223
+ requireWrite(actor, mr.repository);
224
+ MergeRequest managed = mergeRequests.findById(mr.id);
225
+ if (managed == null)
226
+ {
227
+ return;
228
+ }
229
+ if (title != null)
230
+ {
231
+ String trimmed = title.strip();
232
+ if (trimmed.isEmpty())
233
+ {
234
+ throw new InvalidMergeRequestException("Merge request title must not be empty");
235
+ }
236
+ managed.title = trimmed;
237
+ }
238
+ if (description != null)
239
+ {
240
+ managed.description = description.isBlank() ? null : description.strip();
241
+ }
242
+ }
243
+
204
244
private void requireWrite(User actor, Repository repository)
205
245
{
206
246
if (!accessPolicy.canWrite(actor, repository))
MODIFY
src/main/java/de/workaround/mcp/MergeRequestTools.java
+10 -10
@@ -37,24 +37,24 @@
37
37
McpPrincipal principal;
38
38
39
39
@Tool(description = "List the merge requests of a repository.")
40
- public List<ApiModels.MergeRequestView> listMergeRequests(@ToolArg(description = "Owner username") String owner,
40
+ public List<ApiModels.PullView> listMergeRequests(@ToolArg(description = "Owner username") String owner,
41
41
@ToolArg(description = "Repository name") String name)
42
42
{
43
43
Repository repo = access.requireReadable(principal.orNull(), owner, name);
44
- return mergeRequests.list(repo).stream().map(ApiModels.MergeRequestView::of).toList();
44
+ return mergeRequests.list(repo).stream().map(ApiModels.PullView::of).toList();
45
45
}
46
46
47
47
@Tool(description = "Get a single merge request by its number.")
48
- public ApiModels.MergeRequestView getMergeRequest(@ToolArg(description = "Owner username") String owner,
48
+ public ApiModels.PullView getMergeRequest(@ToolArg(description = "Owner username") String owner,
49
49
@ToolArg(description = "Repository name") String name, @ToolArg(description = "Merge request number") int number)
50
50
{
51
51
Repository repo = access.requireReadable(principal.orNull(), owner, name);
52
- return ApiModels.MergeRequestView.of(requireMergeRequest(repo, number));
52
+ return ApiModels.PullView.of(requireMergeRequest(repo, number));
53
53
}
54
54
55
55
@Tool(description = "Open a merge request from a source branch into a target branch. Requires a token and ownership.")
56
56
@Transactional
57
- public ApiModels.MergeRequestView createMergeRequest(@ToolArg(description = "Owner username") String owner,
57
+ public ApiModels.PullView createMergeRequest(@ToolArg(description = "Owner username") String owner,
58
58
@ToolArg(description = "Repository name") String name, @ToolArg(description = "Title") String title,
59
59
@ToolArg(description = "Optional description", defaultValue = "") String description,
60
60
@ToolArg(description = "Source branch") String sourceBranch,
@@ -64,31 +64,31 @@
64
64
Repository repo = access.requireReadable(user, owner, name);
65
65
MergeRequest mr = mergeRequests.create(user, repo, title,
66
66
description == null || description.isBlank() ? null : description, sourceBranch, targetBranch);
67
- return ApiModels.MergeRequestView.of(mr);
67
+ return ApiModels.PullView.of(mr);
68
68
}
69
69
70
70
@Tool(description = "Merge an open merge request. Requires a token and ownership.")
71
71
@Transactional
72
- public ApiModels.MergeRequestView mergeMergeRequest(@ToolArg(description = "Owner username") String owner,
72
+ public ApiModels.PullView mergeMergeRequest(@ToolArg(description = "Owner username") String owner,
73
73
@ToolArg(description = "Repository name") String name, @ToolArg(description = "Merge request number") int number)
74
74
{
75
75
User user = principal.require();
76
76
Repository repo = access.requireReadable(user, owner, name);
77
77
MergeRequest mr = requireMergeRequest(repo, number);
78
78
mergeRequests.merge(user, mr);
79
- return ApiModels.MergeRequestView.of(requireMergeRequest(repo, number));
79
+ return ApiModels.PullView.of(requireMergeRequest(repo, number));
80
80
}
81
81
82
82
@Tool(description = "Close an open merge request without merging. Requires a token and ownership.")
83
83
@Transactional
84
- public ApiModels.MergeRequestView closeMergeRequest(@ToolArg(description = "Owner username") String owner,
84
+ public ApiModels.PullView closeMergeRequest(@ToolArg(description = "Owner username") String owner,
85
85
@ToolArg(description = "Repository name") String name, @ToolArg(description = "Merge request number") int number)
86
86
{
87
87
User user = principal.require();
88
88
Repository repo = access.requireReadable(user, owner, name);
89
89
MergeRequest mr = requireMergeRequest(repo, number);
90
90
mergeRequests.close(user, mr);
91
- return ApiModels.MergeRequestView.of(requireMergeRequest(repo, number));
91
+ return ApiModels.PullView.of(requireMergeRequest(repo, number));
92
92
}
93
93
94
94
@Tool(description = "List the line-level review comments of a merge request.")
MODIFY
src/test/java/de/workaround/api/ApiSmokeIT.java
+2 -2
@@ -61,7 +61,7 @@
61
61
{
62
62
// the seeder creates one demo merge request with a line comment; both must serialize under native
63
63
int number = given()
64
- .when().get("/api/v1/repos/alice/demo/merge-requests")
64
+ .when().get("/api/v1/repos/alice/demo/pulls")
65
65
.then()
66
66
.statusCode(200)
67
67
.contentType("application/json")
@@ -69,7 +69,7 @@
69
69
.extract().path("[0].number");
70
70
71
71
given()
72
- .when().get("/api/v1/repos/alice/demo/merge-requests/" + number + "/comments")
72
+ .when().get("/api/v1/repos/alice/demo/pulls/" + number + "/comments")
73
73
.then()
74
74
.statusCode(200)
75
75
.contentType("application/json")
MODIFY
src/test/java/de/workaround/api/MergeRequestApiTest.java
+118 -18
@@ -21,6 +21,7 @@
21
21
22
22
import static io.restassured.RestAssured.given;
23
23
import static org.hamcrest.CoreMatchers.equalTo;
24
+import static org.hamcrest.CoreMatchers.is;
24
25
25
26
@QuarkusTest
26
27
class MergeRequestApiTest
@@ -40,20 +41,20 @@
40
41
User owner = persistUser("api-mr-owner");
41
42
String token = mintToken(owner);
42
43
seed(owner, "board");
43
- String base = "/api/v1/repos/" + owner.username + "/board/merge-requests";
44
+ String base = "/api/v1/repos/" + owner.username + "/board/pulls";
44
45
45
46
int number = given().header("Authorization", "Bearer " + token)
46
47
.contentType("application/json")
47
- .body(Map.of("title", "Ship it", "description", "adds feature",
48
- "sourceBranch", "feature", "targetBranch", "main"))
48
+ .body(Map.of("title", "Ship it", "body", "adds feature", "head", "feature", "base", "main"))
49
49
.when().post(base)
50
50
.then().statusCode(201)
51
51
.body("title", equalTo("Ship it"))
52
- .body("status", equalTo("OPEN"))
53
- .body("sourceBranch", equalTo("feature"))
54
- .body("targetBranch", equalTo("main"))
52
+ .body("state", equalTo("open"))
53
+ .body("merged", is(false))
54
+ .body("head.ref", equalTo("feature"))
55
+ .body("base.ref", equalTo("main"))
56
+ .body("mergeable", is(true))
55
57
.body("assignee", org.hamcrest.Matchers.nullValue())
56
- .body("reviewer", org.hamcrest.Matchers.nullValue())
57
58
.extract().path("number");
58
59
59
60
given().when().get(base)
@@ -62,32 +63,131 @@
62
63
63
64
given().when().get(base + "/" + number)
64
65
.then().statusCode(200)
65
- .body("title", equalTo("Ship it"));
66
+ .body("title", equalTo("Ship it"))
67
+ .body("body", equalTo("adds feature"));
66
68
67
69
given().header("Authorization", "Bearer " + token)
68
70
.when().post(base + "/" + number + "/merge")
69
71
.then().statusCode(200)
70
- .body("status", equalTo("MERGED"));
72
+ .body("state", equalTo("closed"))
73
+ .body("merged", is(true));
71
74
}
72
75
73
76
@Test
74
- void openMergeRequestCanBeClosed()
77
+ void openMergeRequestCanBeClosedViaPatch()
75
78
{
76
79
User owner = persistUser("api-mr-closer");
77
80
String token = mintToken(owner);
78
81
seed(owner, "closeboard");
79
- String base = "/api/v1/repos/" + owner.username + "/closeboard/merge-requests";
82
+ String base = "/api/v1/repos/" + owner.username + "/closeboard/pulls";
80
83
81
84
int number = given().header("Authorization", "Bearer " + token)
82
85
.contentType("application/json")
83
- .body(Map.of("title", "Never mind", "sourceBranch", "feature", "targetBranch", "main"))
86
+ .body(Map.of("title", "Never mind", "head", "feature", "base", "main"))
84
87
.when().post(base)
85
88
.then().statusCode(201).extract().path("number");
86
89
87
90
given().header("Authorization", "Bearer " + token)
88
- .when().post(base + "/" + number + "/close")
91
+ .contentType("application/json")
92
+ .body(Map.of("state", "closed"))
93
+ .when().patch(base + "/" + number)
89
94
.then().statusCode(200)
90
- .body("status", equalTo("CLOSED"));
95
+ .body("state", equalTo("closed"))
96
+ .body("merged", is(false));
97
+ }
98
+
99
+ @Test
100
+ void closedPullCanBeReopenedViaPatch()
101
+ {
102
+ User owner = persistUser("api-mr-reopener");
103
+ String token = mintToken(owner);
104
+ seed(owner, "reopenboard");
105
+ String base = "/api/v1/repos/" + owner.username + "/reopenboard/pulls";
106
+
107
+ int number = given().header("Authorization", "Bearer " + token)
108
+ .contentType("application/json")
109
+ .body(Map.of("title", "Later", "head", "feature", "base", "main"))
110
+ .when().post(base)
111
+ .then().statusCode(201).extract().path("number");
112
+
113
+ given().header("Authorization", "Bearer " + token).contentType("application/json")
114
+ .body(Map.of("state", "closed")).when().patch(base + "/" + number)
115
+ .then().statusCode(200).body("state", equalTo("closed"));
116
+
117
+ given().header("Authorization", "Bearer " + token).contentType("application/json")
118
+ .body(Map.of("state", "open")).when().patch(base + "/" + number)
119
+ .then().statusCode(200)
120
+ .body("state", equalTo("open"))
121
+ .body("merged", is(false));
122
+ }
123
+
124
+ @Test
125
+ void mergedPullCannotBeReopened()
126
+ {
127
+ User owner = persistUser("api-mr-nomereopen");
128
+ String token = mintToken(owner);
129
+ seed(owner, "nomereopen");
130
+ String base = "/api/v1/repos/" + owner.username + "/nomereopen/pulls";
131
+
132
+ int number = given().header("Authorization", "Bearer " + token)
133
+ .contentType("application/json")
134
+ .body(Map.of("title", "Ship", "head", "feature", "base", "main"))
135
+ .when().post(base)
136
+ .then().statusCode(201).extract().path("number");
137
+
138
+ given().header("Authorization", "Bearer " + token)
139
+ .when().post(base + "/" + number + "/merge")
140
+ .then().statusCode(200).body("merged", is(true));
141
+
142
+ // reopening a merged pull is a no-op: it stays merged/closed
143
+ given().header("Authorization", "Bearer " + token).contentType("application/json")
144
+ .body(Map.of("state", "open")).when().patch(base + "/" + number)
145
+ .then().statusCode(200)
146
+ .body("state", equalTo("closed"))
147
+ .body("merged", is(true));
148
+ }
149
+
150
+ @Test
151
+ void patchWithBlankTitleIsRejected()
152
+ {
153
+ User owner = persistUser("api-mr-blanktitle");
154
+ String token = mintToken(owner);
155
+ seed(owner, "blankboard");
156
+ String base = "/api/v1/repos/" + owner.username + "/blankboard/pulls";
157
+
158
+ int number = given().header("Authorization", "Bearer " + token)
159
+ .contentType("application/json")
160
+ .body(Map.of("title", "Keep", "head", "feature", "base", "main"))
161
+ .when().post(base)
162
+ .then().statusCode(201).extract().path("number");
163
+
164
+ given().header("Authorization", "Bearer " + token).contentType("application/json")
165
+ .body(Map.of("title", " ")).when().patch(base + "/" + number)
166
+ .then().statusCode(400);
167
+ }
168
+
169
+ @Test
170
+ void patchUpdatesTitleAndBody()
171
+ {
172
+ User owner = persistUser("api-mr-editor");
173
+ String token = mintToken(owner);
174
+ seed(owner, "editboard");
175
+ String base = "/api/v1/repos/" + owner.username + "/editboard/pulls";
176
+
177
+ int number = given().header("Authorization", "Bearer " + token)
178
+ .contentType("application/json")
179
+ .body(Map.of("title", "Original", "body", "old", "head", "feature", "base", "main"))
180
+ .when().post(base)
181
+ .then().statusCode(201).extract().path("number");
182
+
183
+ given().header("Authorization", "Bearer " + token)
184
+ .contentType("application/json")
185
+ .body(Map.of("title", "Renamed", "body", "new"))
186
+ .when().patch(base + "/" + number)
187
+ .then().statusCode(200)
188
+ .body("title", equalTo("Renamed"))
189
+ .body("body", equalTo("new"))
190
+ .body("state", equalTo("open"));
91
191
}
92
192
93
193
@Test
@@ -99,8 +199,8 @@
99
199
100
200
given().header("Authorization", "Bearer " + token)
101
201
.contentType("application/json")
102
- .body(Map.of("title", "bad", "sourceBranch", "ghost", "targetBranch", "main"))
103
- .when().post("/api/v1/repos/" + owner.username + "/bb/merge-requests")
202
+ .body(Map.of("title", "bad", "head", "ghost", "base", "main"))
203
+ .when().post("/api/v1/repos/" + owner.username + "/bb/pulls")
104
204
.then().statusCode(400);
105
205
}
106
206
@@ -111,8 +211,8 @@
111
211
seed(owner, "board");
112
212
113
213
given().contentType("application/json")
114
- .body(Map.of("title", "sneaky", "sourceBranch", "feature", "targetBranch", "main"))
115
- .when().post("/api/v1/repos/" + owner.username + "/board/merge-requests")
214
+ .body(Map.of("title", "sneaky", "head", "feature", "base", "main"))
215
+ .when().post("/api/v1/repos/" + owner.username + "/board/pulls")
116
216
.then().statusCode(401);
117
217
}
118
218
MODIFY
src/test/java/de/workaround/api/MergeRequestCommentApiTest.java
+5 -5
@@ -45,7 +45,7 @@
45
45
{
46
46
User owner = persistUser("api-c-owner");
47
47
int number = seedRepoWithMergeRequest(owner, "cboard");
48
- String base = "/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments";
48
+ String base = "/api/v1/repos/" + owner.username + "/cboard/pulls/" + number + "/comments";
49
49
50
50
// a different authenticated user (reader of the public repo) can comment
51
51
User reader = persistUser("api-c-reader");
@@ -80,7 +80,7 @@
80
80
81
81
given().contentType("application/json")
82
82
.body(anchor("sneaky"))
83
- .when().post("/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments")
83
+ .when().post("/api/v1/repos/" + owner.username + "/cboard/pulls/" + number + "/comments")
84
84
.then().statusCode(401);
85
85
}
86
86
@@ -100,7 +100,7 @@
100
100
given().header("Authorization", "Bearer " + token)
101
101
.contentType("application/json")
102
102
.body(body)
103
- .when().post("/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments")
103
+ .when().post("/api/v1/repos/" + owner.username + "/cboard/pulls/" + number + "/comments")
104
104
.then().statusCode(400);
105
105
}
106
106
@@ -110,7 +110,7 @@
110
110
User owner = persistUser("api-c-owner2-" + shortId());
111
111
String ownerToken = mintToken(owner);
112
112
int number = seedRepoWithMergeRequest(owner, "cboard");
113
- String base = "/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments";
113
+ String base = "/api/v1/repos/" + owner.username + "/cboard/pulls/" + number + "/comments";
114
114
115
115
User reader = persistUser("api-c-reader2-" + shortId());
116
116
String readerToken = mintToken(reader);
@@ -128,7 +128,7 @@
128
128
{
129
129
User owner = persistUser("api-c-owner3-" + shortId());
130
130
int number = seedRepoWithMergeRequest(owner, "cboard");
131
- String base = "/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments";
131
+ String base = "/api/v1/repos/" + owner.username + "/cboard/pulls/" + number + "/comments";
132
132
133
133
User reader = persistUser("api-c-reader3-" + shortId());
134
134
String readerToken = mintToken(reader);