gitshark

Clone repository

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

← Commits

✨ (merge-requests): Address merge requests by per-repo number in URLs

b09bf616657202e0c1096b94d7d3c706d23d2106 · Michael Hainz · 2026-07-16T12:52:06Z

Changes

7 files changed, +96 -49

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index a12abd3..8f271b4 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
23 23 - 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
24 24 - 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
25 25 - 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
26 -- 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; 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
26 +- 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; 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
27 27 - 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)
28 28 - 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
29 29 - 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
MODIFY src/main/java/de/workaround/notify/MergeRequestNotificationSource.java +1 -1
diff --git a/src/main/java/de/workaround/notify/MergeRequestNotificationSource.java b/src/main/java/de/workaround/notify/MergeRequestNotificationSource.java
index c727cd7..70fb0ae 100644
--- a/src/main/java/de/workaround/notify/MergeRequestNotificationSource.java
+++ b/src/main/java/de/workaround/notify/MergeRequestNotificationSource.java
@@ -32,7 +32,7 @@
32 32 return mergeRequests.findOpenInvolving(user).stream()
33 33 .filter(mr -> accessPolicy.canRead(user, mr.repository))
34 34 .map(mr -> new NotificationItem("merge-request", "!" + mr.number + " " + mr.title, mr.repository,
35 - "/repos/" + mr.repository.ownerHandle() + "/" + mr.repository.name + "/merge-requests/" + mr.id))
35 + "/repos/" + mr.repository.ownerHandle() + "/" + mr.repository.name + "/merge-requests/" + mr.number))
36 36 .toList();
37 37 }
38 38 }
MODIFY src/main/java/de/workaround/web/MergeRequestResource.java +41 -30
diff --git a/src/main/java/de/workaround/web/MergeRequestResource.java b/src/main/java/de/workaround/web/MergeRequestResource.java
index 42a262e..39eeb75 100644
--- a/src/main/java/de/workaround/web/MergeRequestResource.java
+++ b/src/main/java/de/workaround/web/MergeRequestResource.java
@@ -131,16 +131,16 @@
131 131 Repository repo = requireReadable(owner, name);
132 132 MergeRequest mr = mergeRequestService.create(currentUser.require(), repo, title, description, sourceBranch,
133 133 targetBranch);
134 - return Response.seeOther(detailUri(repo, mr.id)).build();
134 + return Response.seeOther(detailUri(repo, mr.number)).build();
135 135 }
136 136
137 137 @GET
138 - @jakarta.ws.rs.Path("{id}")
138 + @jakarta.ws.rs.Path("{number:\\d+}")
139 139 public TemplateInstance detail(@PathParam("owner") String owner, @PathParam("name") String name,
140 - @PathParam("id") String id)
140 + @PathParam("number") int number)
141 141 {
142 142 Repository repo = requireReadable(owner, name);
143 - MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
143 + MergeRequest mr = mergeRequestService.find(repo, number).orElseThrow(NotFoundException::new);
144 144 GitMergeService.DiffView diff = mergeRequestService.diff(mr).orElse(null);
145 145 List<MergeRequestComment> comments = commentService.list(mr);
146 146 User user = currentUser.get();
@@ -195,35 +195,46 @@
195 195 return users;
196 196 }
197 197
198 + /** Merge requests were originally addressed by UUID; keep old bookmarks and federated links working. */
199 + @GET
200 + @jakarta.ws.rs.Path("{id:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}")
201 + public Response legacyDetail(@PathParam("owner") String owner, @PathParam("name") String name,
202 + @PathParam("id") UUID id)
203 + {
204 + Repository repo = requireReadable(owner, name);
205 + MergeRequest mr = mergeRequestService.find(repo, id).orElseThrow(NotFoundException::new);
206 + return Response.status(Response.Status.MOVED_PERMANENTLY).location(detailUri(repo, mr.number)).build();
207 + }
208 +
198 209 @POST
199 - @jakarta.ws.rs.Path("{id}/comments")
210 + @jakarta.ws.rs.Path("{number:\\d+}/comments")
200 211 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
201 212 public Response comment(@PathParam("owner") String owner, @PathParam("name") String name,
202 - @PathParam("id") String id, @FormParam("filePath") String filePath,
213 + @PathParam("number") int number, @FormParam("filePath") String filePath,
203 214 @FormParam("oldLine") @DefaultValue("-1") int oldLine, @FormParam("newLine") @DefaultValue("-1") int newLine,
204 215 @FormParam("body") String body)
205 216 {
206 217 Repository repo = requireReadable(owner, name);
207 - MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
218 + MergeRequest mr = mergeRequestService.find(repo, number).orElseThrow(NotFoundException::new);
208 219 commentService.add(currentUser.require(), mr, filePath, oldLine, newLine, body);
209 - return Response.seeOther(detailUri(repo, mr.id)).build();
220 + return Response.seeOther(detailUri(repo, mr.number)).build();
210 221 }
211 222
212 223 @POST
213 - @jakarta.ws.rs.Path("{id}/comments/{commentId}/delete")
224 + @jakarta.ws.rs.Path("{number:\\d+}/comments/{commentId}/delete")
214 225 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
215 226 public Response deleteComment(@PathParam("owner") String owner, @PathParam("name") String name,
216 - @PathParam("id") String id, @PathParam("commentId") String commentId)
227 + @PathParam("number") int number, @PathParam("commentId") String commentId)
217 228 {
218 229 Repository repo = requireReadable(owner, name);
219 - MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
230 + MergeRequest mr = mergeRequestService.find(repo, number).orElseThrow(NotFoundException::new);
220 231 MergeRequestComment comment = commentRepo.findById(parseId(commentId));
221 232 if (comment == null || !comment.mergeRequest.id.equals(mr.id))
222 233 {
223 234 throw new NotFoundException();
224 235 }
225 236 commentService.delete(currentUser.require(), comment);
226 - return Response.seeOther(detailUri(repo, mr.id)).build();
237 + return Response.seeOther(detailUri(repo, mr.number)).build();
227 238 }
228 239
229 240 private static boolean isContent(String lineType)
@@ -232,57 +243,57 @@
232 243 }
233 244
234 245 @POST
235 - @jakarta.ws.rs.Path("{id}/merge")
246 + @jakarta.ws.rs.Path("{number:\\d+}/merge")
236 247 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
237 248 public Response merge(@PathParam("owner") String owner, @PathParam("name") String name,
238 - @PathParam("id") String id)
249 + @PathParam("number") int number)
239 250 {
240 251 Repository repo = requireReadable(owner, name);
241 - MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
252 + MergeRequest mr = mergeRequestService.find(repo, number).orElseThrow(NotFoundException::new);
242 253 mergeRequestService.merge(currentUser.require(), mr);
243 - return Response.seeOther(detailUri(repo, mr.id)).build();
254 + return Response.seeOther(detailUri(repo, mr.number)).build();
244 255 }
245 256
246 257 @POST
247 - @jakarta.ws.rs.Path("{id}/assign")
258 + @jakarta.ws.rs.Path("{number:\\d+}/assign")
248 259 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
249 260 public Response assign(@PathParam("owner") String owner, @PathParam("name") String name,
250 - @PathParam("id") String id, @FormParam("assignee") String assignee)
261 + @PathParam("number") int number, @FormParam("assignee") String assignee)
251 262 {
252 263 Repository repo = requireReadable(owner, name);
253 - MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
264 + MergeRequest mr = mergeRequestService.find(repo, number).orElseThrow(NotFoundException::new);
254 265 // username resolution/validation lives in the service (InvalidMergeRequestException -> 400 via mapper)
255 266 mergeRequestService.assign(currentUser.require(), mr, assignee);
256 - return Response.seeOther(detailUri(repo, mr.id)).build();
267 + return Response.seeOther(detailUri(repo, mr.number)).build();
257 268 }
258 269
259 270 @POST
260 - @jakarta.ws.rs.Path("{id}/reviewer")
271 + @jakarta.ws.rs.Path("{number:\\d+}/reviewer")
261 272 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
262 273 public Response reviewer(@PathParam("owner") String owner, @PathParam("name") String name,
263 - @PathParam("id") String id, @FormParam("reviewer") String reviewer)
274 + @PathParam("number") int number, @FormParam("reviewer") String reviewer)
264 275 {
265 276 Repository repo = requireReadable(owner, name);
266 - MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
277 + MergeRequest mr = mergeRequestService.find(repo, number).orElseThrow(NotFoundException::new);
267 278 mergeRequestService.setReviewer(currentUser.require(), mr, reviewer);
268 - return Response.seeOther(detailUri(repo, mr.id)).build();
279 + return Response.seeOther(detailUri(repo, mr.number)).build();
269 280 }
270 281
271 282 @POST
272 - @jakarta.ws.rs.Path("{id}/close")
283 + @jakarta.ws.rs.Path("{number:\\d+}/close")
273 284 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
274 285 public Response close(@PathParam("owner") String owner, @PathParam("name") String name,
275 - @PathParam("id") String id)
286 + @PathParam("number") int number)
276 287 {
277 288 Repository repo = requireReadable(owner, name);
278 - MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
289 + MergeRequest mr = mergeRequestService.find(repo, number).orElseThrow(NotFoundException::new);
279 290 mergeRequestService.close(currentUser.require(), mr);
280 - return Response.seeOther(detailUri(repo, mr.id)).build();
291 + return Response.seeOther(detailUri(repo, mr.number)).build();
281 292 }
282 293
283 - private URI detailUri(Repository repo, UUID id)
294 + private URI detailUri(Repository repo, int number)
284 295 {
285 - return URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name + "/merge-requests/" + id);
296 + return URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name + "/merge-requests/" + number);
286 297 }
287 298
288 299 private boolean isOwner(Repository repo)
MODIFY src/main/resources/templates/MergeRequestResource/mergeRequest.html +10 -10
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequest.html b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
index 1b32289..231a51b 100644
--- a/src/main/resources/templates/MergeRequestResource/mergeRequest.html
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
@@ -24,13 +24,13 @@
24 24 </summary>
25 25 <div class="assignee-menu">
26 26 <p class="assignee-menu-head">Assign this merge request</p>
27 - <form class="assignee-search" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/assign">
27 + <form class="assignee-search" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/assign">
28 28 <input type="text" name="assignee" list="mr-people" placeholder="Type a username&hellip;" autocomplete="off">
29 29 </form>
30 30 <ul class="assignee-options">
31 31 {#for candidate in assignees}
32 32 <li>
33 - <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/assign">
33 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/assign">
34 34 <button type="submit" name="assignee" value="{candidate.username}"
35 35 class="assignee-option{#if mr.assignee && candidate.username == mr.assignee.username} is-selected{/if}">
36 36 <span class="assignee-check" aria-hidden="true">{#if mr.assignee && candidate.username == mr.assignee.username}&check;{/if}</span>
@@ -41,7 +41,7 @@
41 41 {/for}
42 42 </ul>
43 43 {#if mr.assignee}
44 - <form class="assignee-clear" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/assign">
44 + <form class="assignee-clear" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/assign">
45 45 <button type="submit" class="assignee-option assignee-clear-btn">Clear assignee</button>
46 46 </form>
47 47 {/if}
@@ -62,13 +62,13 @@
62 62 </summary>
63 63 <div class="assignee-menu">
64 64 <p class="assignee-menu-head">Request a review</p>
65 - <form class="assignee-search" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/reviewer">
65 + <form class="assignee-search" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/reviewer">
66 66 <input type="text" name="reviewer" list="mr-people" placeholder="Type a username&hellip;" autocomplete="off">
67 67 </form>
68 68 <ul class="assignee-options">
69 69 {#for candidate in assignees}
70 70 <li>
71 - <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/reviewer">
71 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/reviewer">
72 72 <button type="submit" name="reviewer" value="{candidate.username}"
73 73 class="assignee-option{#if mr.reviewer && candidate.username == mr.reviewer.username} is-selected{/if}">
74 74 <span class="assignee-check" aria-hidden="true">{#if mr.reviewer && candidate.username == mr.reviewer.username}&check;{/if}</span>
@@ -79,7 +79,7 @@
79 79 {/for}
80 80 </ul>
81 81 {#if mr.reviewer}
82 - <form class="assignee-clear" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/reviewer">
82 + <form class="assignee-clear" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/reviewer">
83 83 <button type="submit" class="assignee-option assignee-clear-btn">Clear reviewer</button>
84 84 </form>
85 85 {/if}
@@ -98,10 +98,10 @@
98 98 </div>
99 99 {#if owner && mr.status.name() == 'OPEN'}
100 100 <div class="issue-actions">
101 - <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/merge">
101 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/merge">
102 102 <button type="submit" class="btn btn-primary">Merge</button>
103 103 </form>
104 - <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/close"
104 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/close"
105 105 onsubmit="return confirm('Close this merge request without merging?')">
106 106 <button type="submit" class="btn btn-secondary">Close</button>
107 107 </form>
@@ -141,7 +141,7 @@
141 141 </div>
142 142 {#if lv.commentable}
143 143 <div class="dl-comment-form">
144 - <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/comments">
144 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/comments">
145 145 <input type="hidden" name="filePath" value="{file.path}">
146 146 <input type="hidden" name="oldLine" value="{lv.line.oldLine}">
147 147 <input type="hidden" name="newLine" value="{lv.line.newLine}">
@@ -163,7 +163,7 @@
163 163 </div>
164 164 {#if owner || c.author.id == currentUserId}
165 165 <form class="comment-del-form" method="post"
166 - action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/comments/{c.id}/delete">
166 + action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}/comments/{c.id}/delete">
167 167 <button type="submit" class="comment-del" title="Delete comment" aria-label="Delete comment">
168 168 <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
169 169 </button>
MODIFY src/main/resources/templates/MergeRequestResource/mergeRequests.html +2 -2
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequests.html b/src/main/resources/templates/MergeRequestResource/mergeRequests.html
index 38a4f18..b2efbba 100644
--- a/src/main/resources/templates/MergeRequestResource/mergeRequests.html
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequests.html
@@ -14,7 +14,7 @@
14 14 {#else}
15 15 <div class="panel">
16 16 {#for mr in open}
17 - <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}">
17 + <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}">
18 18 <span class="fname">
19 19 <span class="badge status-{mr.status}">{mr.status.label}</span>
20 20 <span class="n">{mr.title}</span>
@@ -30,7 +30,7 @@
30 30 <summary>Archive <span class="ct">{closed.size}</span></summary>
31 31 <div class="panel">
32 32 {#for mr in closed}
33 - <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}">
33 + <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.number}">
34 34 <span class="fname">
35 35 <span class="badge status-{mr.status}">{mr.status.label}</span>
36 36 <span class="n">{mr.title}</span>
MODIFY src/test/java/de/workaround/web/MergeRequestCommentUiTest.java +4 -4
diff --git a/src/test/java/de/workaround/web/MergeRequestCommentUiTest.java b/src/test/java/de/workaround/web/MergeRequestCommentUiTest.java
index 47a217a..43f6409 100644
--- a/src/test/java/de/workaround/web/MergeRequestCommentUiTest.java
+++ b/src/test/java/de/workaround/web/MergeRequestCommentUiTest.java
@@ -48,7 +48,7 @@
48 48 {
49 49 User owner = persistUser("cmu-owner");
50 50 MergeRequest mr = seededMr(owner, "board");
51 - String detail = "/repos/" + owner.username + "/board/merge-requests/" + mr.id;
51 + String detail = "/repos/" + owner.username + "/board/merge-requests/" + mr.number;
52 52
53 53 // a commentable line exposes a hover comment icon (not a full-width placeholder bar)
54 54 given().when().get(detail)
@@ -75,7 +75,7 @@
75 75 persistUser("cmu-reader");
76 76 User owner = persistUser("cmu-owner2-" + UUID.randomUUID().toString().substring(0, 8));
77 77 MergeRequest mr = seededMr(owner, "board");
78 - String detail = "/repos/" + owner.username + "/board/merge-requests/" + mr.id;
78 + String detail = "/repos/" + owner.username + "/board/merge-requests/" + mr.number;
79 79
80 80 given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
81 81 .formParam("filePath", "feature.txt").formParam("oldLine", "-1").formParam("newLine", "1")
@@ -91,7 +91,7 @@
91 91 {
92 92 User owner = persistUser("cmu-anon-" + UUID.randomUUID().toString().substring(0, 8));
93 93 MergeRequest mr = seededMr(owner, "board");
94 - String detail = "/repos/" + owner.username + "/board/merge-requests/" + mr.id;
94 + String detail = "/repos/" + owner.username + "/board/merge-requests/" + mr.number;
95 95
96 96 given().contentType("application/x-www-form-urlencoded")
97 97 .formParam("filePath", "feature.txt").formParam("oldLine", "-1").formParam("newLine", "1")
@@ -107,7 +107,7 @@
107 107 User owner = persistUser("cmu-del");
108 108 MergeRequest mr = seededMr(owner, "board");
109 109 MergeRequestComment comment = comments.add(owner, mr, "feature.txt", -1, 1, "temporary note");
110 - String detail = "/repos/" + owner.username + "/board/merge-requests/" + mr.id;
110 + String detail = "/repos/" + owner.username + "/board/merge-requests/" + mr.number;
111 111
112 112 given().when().get(detail).then().statusCode(200).body(containsString("temporary note"));
113 113
MODIFY src/test/java/de/workaround/web/MergeRequestUiTest.java +37 -1
diff --git a/src/test/java/de/workaround/web/MergeRequestUiTest.java b/src/test/java/de/workaround/web/MergeRequestUiTest.java
index fde7405..e7ead35 100644
--- a/src/test/java/de/workaround/web/MergeRequestUiTest.java
+++ b/src/test/java/de/workaround/web/MergeRequestUiTest.java
@@ -22,6 +22,7 @@
22 22
23 23 import static io.restassured.RestAssured.given;
24 24 import static org.hamcrest.CoreMatchers.containsString;
25 +import static org.hamcrest.CoreMatchers.endsWith;
25 26 import static org.hamcrest.CoreMatchers.not;
26 27
27 28 @QuarkusTest
@@ -167,11 +168,46 @@
167 168 var mr = mergeRequestService.create(owner, repo, "x", null, "feature", "main");
168 169
169 170 given().contentType("application/x-www-form-urlencoded").formParam("assignee", owner.username)
170 - .when().post("/repos/" + owner.username + "/noassignboard/merge-requests/" + mr.id + "/assign")
171 + .when().post("/repos/" + owner.username + "/noassignboard/merge-requests/" + mr.number + "/assign")
171 172 .then().statusCode(403);
172 173 }
173 174
174 175 @Test
176 + @TestSecurity(user = "mru-numbers")
177 + void mergeRequestsUseSequentialNumbersInUrls()
178 + {
179 + User owner = persistUser("mru-numbers");
180 + seed(owner, "numboard");
181 + String base = "/repos/" + owner.username + "/numboard/merge-requests";
182 +
183 + // the first MR gets #1; the redirect URL is the number, not a UUID
184 + given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
185 + .formParam("title", "First MR").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
186 + .when().post(base).then().statusCode(303)
187 + .header("Location", endsWith("/merge-requests/1"));
188 +
189 + // reachable by number
190 + given().when().get(base + "/1").then().statusCode(200).body(containsString("First MR"));
191 +
192 + // the list links to the number URL
193 + given().when().get(base).then().statusCode(200).body(containsString("/merge-requests/1\""));
194 + }
195 +
196 + @Test
197 + @TestSecurity(user = "mru-legacy")
198 + void legacyUuidUrlRedirectsToNumber()
199 + {
200 + User owner = persistUser("mru-legacy");
201 + Repository repo = seed(owner, "legboard");
202 + var mr = mergeRequestService.create(owner, repo, "Legacy link", null, "feature", "main");
203 + String base = "/repos/" + owner.username + "/legboard/merge-requests";
204 +
205 + given().redirects().follow(false).when().get(base + "/" + mr.id)
206 + .then().statusCode(301)
207 + .header("Location", containsString("/merge-requests/" + mr.number));
208 + }
209 +
210 + @Test
175 211 void anonymousCannotCreateMergeRequests()
176 212 {
177 213 User owner = persistUser("mru-anon-" + UUID.randomUUID().toString().substring(0, 8));

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog