gitshark

Clone repository

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

← Commits

✨ (api): Migrate merge-requests to a Gitea pulls resource

73e555e7f9a1bd7cc268bf1ff6297173248c140a · Michael Hainz · 2026-07-21T11:39:16Z

Changes

10 files changed, +447 -230

MODIFY README.md +6 -7
diff --git a/README.md b/README.md
index 7b4b93e..48534a9 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/maintainers/gitea-api.md b/docs/maintainers/gitea-api.md
index 2cb873f..2e3c416 100644
--- a/docs/maintainers/gitea-api.md
+++ b/docs/maintainers/gitea-api.md
@@ -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
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index ce23284..3cec6d3 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -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
diff --git a/src/main/java/de/workaround/api/MergeRequestApiResource.java b/src/main/java/de/workaround/api/MergeRequestApiResource.java
deleted file mode 100644
index 89bd30a..0000000
--- a/src/main/java/de/workaround/api/MergeRequestApiResource.java
+++ /dev/null
@@ -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
diff --git a/src/main/java/de/workaround/api/PullApiResource.java b/src/main/java/de/workaround/api/PullApiResource.java
new file mode 100644
index 0000000..a0eaef5
--- /dev/null
+++ b/src/main/java/de/workaround/api/PullApiResource.java
@@ -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
diff --git a/src/main/java/de/workaround/git/MergeRequestService.java b/src/main/java/de/workaround/git/MergeRequestService.java
index b3b142e..4c99656 100644
--- a/src/main/java/de/workaround/git/MergeRequestService.java
+++ b/src/main/java/de/workaround/git/MergeRequestService.java
@@ -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
diff --git a/src/main/java/de/workaround/mcp/MergeRequestTools.java b/src/main/java/de/workaround/mcp/MergeRequestTools.java
index e1f35b3..0d5b84e 100644
--- a/src/main/java/de/workaround/mcp/MergeRequestTools.java
+++ b/src/main/java/de/workaround/mcp/MergeRequestTools.java
@@ -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
diff --git a/src/test/java/de/workaround/api/ApiSmokeIT.java b/src/test/java/de/workaround/api/ApiSmokeIT.java
index 904ffa7..6379906 100644
--- a/src/test/java/de/workaround/api/ApiSmokeIT.java
+++ b/src/test/java/de/workaround/api/ApiSmokeIT.java
@@ -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
diff --git a/src/test/java/de/workaround/api/MergeRequestApiTest.java b/src/test/java/de/workaround/api/MergeRequestApiTest.java
index 72b0f00..8783116 100644
--- a/src/test/java/de/workaround/api/MergeRequestApiTest.java
+++ b/src/test/java/de/workaround/api/MergeRequestApiTest.java
@@ -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
diff --git a/src/test/java/de/workaround/api/MergeRequestCommentApiTest.java b/src/test/java/de/workaround/api/MergeRequestCommentApiTest.java
index 877e0e3..481c583 100644
--- a/src/test/java/de/workaround/api/MergeRequestCommentApiTest.java
+++ b/src/test/java/de/workaround/api/MergeRequestCommentApiTest.java
@@ -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);

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog