gitshark

Clone repository

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

← Commits

✨ (merge-requests): Assign an assignee and reviewer

de5fb215f54b0b072e4af155a8a8dbd2a145cde5 · Phillip Souza Furtner · 2026-07-16T07:16:50Z

Changes

11 files changed, +362 -6

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index 260fd79..1d3cf3b 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' `#`), and an author; 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
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 - 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
29 29 - The owner or a collaborator can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
MODIFY src/main/java/de/workaround/api/ApiModels.java +4 -2
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index 756c204..d8aa554 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -54,12 +54,14 @@
54 54 }
55 55
56 56 public record MergeRequestView(int number, String title, String description, String sourceBranch,
57 - String targetBranch, MergeRequest.Status status, String author, Instant createdAt, Instant mergedAt)
57 + String targetBranch, MergeRequest.Status status, String author, String assignee, String reviewer,
58 + Instant createdAt, Instant mergedAt)
58 59 {
59 60 public static MergeRequestView of(MergeRequest mr)
60 61 {
61 62 return new MergeRequestView(mr.number, mr.title, mr.description, mr.sourceBranch, mr.targetBranch,
62 - mr.status, mr.author.username, mr.createdAt, mr.mergedAt);
63 + mr.status, mr.author.username, mr.assignee == null ? null : mr.assignee.username,
64 + mr.reviewer == null ? null : mr.reviewer.username, mr.createdAt, mr.mergedAt);
63 65 }
64 66 }
65 67
MODIFY src/main/java/de/workaround/git/MergeRequestService.java +47 -0
diff --git a/src/main/java/de/workaround/git/MergeRequestService.java b/src/main/java/de/workaround/git/MergeRequestService.java
index 5d4ea1d..b3b142e 100644
--- a/src/main/java/de/workaround/git/MergeRequestService.java
+++ b/src/main/java/de/workaround/git/MergeRequestService.java
@@ -25,6 +25,9 @@
25 25 MergeRequest.Repo mergeRequests;
26 26
27 27 @Inject
28 + User.Repo users;
29 +
30 + @Inject
28 31 AccessPolicy accessPolicy;
29 32
30 33 @Inject
@@ -142,6 +145,50 @@
142 145 return result;
143 146 }
144 147
148 + /**
149 + * Assigns the merge request to the local user with the given username, or unassigns it when the
150 + * username is blank/null. Any existing user can be named — assignment itself needs no repo access.
151 + */
152 + @Transactional
153 + public void assign(User actor, MergeRequest mr, String username)
154 + {
155 + requireWrite(actor, mr.repository);
156 + User assignee = resolveUser(username);
157 + MergeRequest managed = mergeRequests.findById(mr.id);
158 + if (managed != null)
159 + {
160 + managed.assignee = assignee;
161 + }
162 + }
163 +
164 + /**
165 + * Sets the merge request's reviewer to the local user with the given username, or clears it when the
166 + * username is blank/null. Any existing user can be named — setting a reviewer needs no repo access.
167 + */
168 + @Transactional
169 + public void setReviewer(User actor, MergeRequest mr, String username)
170 + {
171 + requireWrite(actor, mr.repository);
172 + User reviewer = resolveUser(username);
173 + MergeRequest managed = mergeRequests.findById(mr.id);
174 + if (managed != null)
175 + {
176 + managed.reviewer = reviewer;
177 + }
178 + }
179 +
180 + /** Resolves a username to a local user, or null when blank; an unknown handle is a 400-level error. */
181 + private User resolveUser(String username)
182 + {
183 + String handle = username == null ? "" : username.strip();
184 + if (handle.isEmpty())
185 + {
186 + return null;
187 + }
188 + return users.findByUsername(handle)
189 + .orElseThrow(() -> new InvalidMergeRequestException("No user with that username exists."));
190 + }
191 +
145 192 /** Moves an open merge request to CLOSED without merging. */
146 193 @Transactional
147 194 public void close(User actor, MergeRequest mr)
MODIFY src/main/java/de/workaround/model/MergeRequest.java +9 -1
diff --git a/src/main/java/de/workaround/model/MergeRequest.java b/src/main/java/de/workaround/model/MergeRequest.java
index a30f073..c7c4ebd 100644
--- a/src/main/java/de/workaround/model/MergeRequest.java
+++ b/src/main/java/de/workaround/model/MergeRequest.java
@@ -38,6 +38,14 @@
38 38 @ManyToOne(optional = false)
39 39 public User author;
40 40
41 + /** Optional person responsible for the change; null means nobody is assigned. Cleared if that user is deleted. */
42 + @ManyToOne
43 + public User assignee;
44 +
45 + /** Optional person asked to review the change; null means no reviewer. Cleared if that user is deleted. */
46 + @ManyToOne
47 + public User reviewer;
48 +
41 49 /** Per-repository, human-facing number (#1, #2, ...) assigned on creation; unique within the repository. */
42 50 public int number;
43 51
@@ -74,7 +82,7 @@
74 82
75 83 public interface Repo extends PanacheRepository.Managed<MergeRequest, UUID>
76 84 {
77 - @HQL("select mr from MergeRequest mr join fetch mr.author where mr.repository = :repository order by mr.createdAt desc")
85 + @HQL("select mr from MergeRequest mr join fetch mr.author left join fetch mr.assignee left join fetch mr.reviewer where mr.repository = :repository order by mr.createdAt desc")
78 86 List<MergeRequest> findByRepository(Repository repository);
79 87
80 88 @Find
MODIFY src/main/java/de/workaround/web/MergeRequestResource.java +47 -2
diff --git a/src/main/java/de/workaround/web/MergeRequestResource.java b/src/main/java/de/workaround/web/MergeRequestResource.java
index e111390..42a262e 100644
--- a/src/main/java/de/workaround/web/MergeRequestResource.java
+++ b/src/main/java/de/workaround/web/MergeRequestResource.java
@@ -8,6 +8,7 @@
8 8
9 9 import de.workaround.account.CurrentUser;
10 10 import de.workaround.git.AccessPolicy;
11 +import de.workaround.git.CollaboratorService;
11 12 import de.workaround.git.ForbiddenOperationException;
12 13 import de.workaround.git.GitBrowseService;
13 14 import de.workaround.git.GitMergeService;
@@ -48,7 +49,8 @@
48 49 String defaultBranch);
49 50
50 51 static native TemplateInstance mergeRequest(Repository repo, RepoNav nav, boolean owner, boolean loggedIn,
51 - UUID currentUserId, MergeRequest mr, List<FileDiffView> files, int additions, int deletions);
52 + UUID currentUserId, MergeRequest mr, List<FileDiffView> files, int additions, int deletions,
53 + List<User> assignees);
52 54 }
53 55
54 56 /**
@@ -84,6 +86,9 @@
84 86 MergeRequestCommentService commentService;
85 87
86 88 @Inject
89 + CollaboratorService collaboratorService;
90 +
91 + @Inject
87 92 MergeRequestComment.Repo commentRepo;
88 93
89 94 @Inject
@@ -172,7 +177,22 @@
172 177 }
173 178 }
174 179 return Templates.mergeRequest(repo, repoNav.build(repo, uriInfo), isOwner(repo), loggedIn, currentUserId, mr,
175 - files, additions, deletions);
180 + files, additions, deletions, assignableUsers(repo));
181 + }
182 +
183 + /**
184 + * Suggestions offered by the assignee/reviewer pickers: the repository owner (for personal repos) plus
185 + * every collaborator. Assignment itself accepts any username; this list only powers the picker menus.
186 + */
187 + private List<User> assignableUsers(Repository repo)
188 + {
189 + List<User> users = new ArrayList<>();
190 + if (repo.ownerUser != null)
191 + {
192 + users.add(repo.ownerUser);
193 + }
194 + collaboratorService.list(repo).forEach(collaborator -> users.add(collaborator.user));
195 + return users;
176 196 }
177 197
178 198 @POST
@@ -224,6 +244,31 @@
224 244 }
225 245
226 246 @POST
247 + @jakarta.ws.rs.Path("{id}/assign")
248 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
249 + public Response assign(@PathParam("owner") String owner, @PathParam("name") String name,
250 + @PathParam("id") String id, @FormParam("assignee") String assignee)
251 + {
252 + Repository repo = requireReadable(owner, name);
253 + MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
254 + // username resolution/validation lives in the service (InvalidMergeRequestException -> 400 via mapper)
255 + mergeRequestService.assign(currentUser.require(), mr, assignee);
256 + return Response.seeOther(detailUri(repo, mr.id)).build();
257 + }
258 +
259 + @POST
260 + @jakarta.ws.rs.Path("{id}/reviewer")
261 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
262 + public Response reviewer(@PathParam("owner") String owner, @PathParam("name") String name,
263 + @PathParam("id") String id, @FormParam("reviewer") String reviewer)
264 + {
265 + Repository repo = requireReadable(owner, name);
266 + MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
267 + mergeRequestService.setReviewer(currentUser.require(), mr, reviewer);
268 + return Response.seeOther(detailUri(repo, mr.id)).build();
269 + }
270 +
271 + @POST
227 272 @jakarta.ws.rs.Path("{id}/close")
228 273 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
229 274 public Response close(@PathParam("owner") String owner, @PathParam("name") String name,
MODIFY src/main/resources/META-INF/resources/shark.css +21 -0
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 939f6ed..4210f5d 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -1238,6 +1238,27 @@
1238 1238 background: var(--danger-bg);
1239 1239 }
1240 1240
1241 +/* merge-request metadata strip: assignee + reviewer pickers sit side by side above the diff */
1242 +.mr-meta {
1243 + display: flex;
1244 + flex-wrap: wrap;
1245 + gap: var(--s6);
1246 + margin: var(--s4) 0 var(--s5);
1247 + padding: var(--s4) 0;
1248 + border-top: 1px solid var(--border-soft);
1249 + border-bottom: 1px solid var(--border-soft);
1250 +}
1251 +
1252 +.mr-meta-field {
1253 + position: relative;
1254 + width: 240px;
1255 + max-width: 100%;
1256 +}
1257 +
1258 +.mr-meta-field .issue-side-label {
1259 + margin: 0 0 var(--s2);
1260 +}
1261 +
1241 1262 /* assignee avatar pinned to the right of an issue list row */
1242 1263 .frow-assignee {
1243 1264 margin-left: auto;
ADD src/main/resources/db/migration/V18__merge_request_assignee_reviewer.sql +8 -0
diff --git a/src/main/resources/db/migration/V18__merge_request_assignee_reviewer.sql b/src/main/resources/db/migration/V18__merge_request_assignee_reviewer.sql
new file mode 100644
index 0000000..381bba0
--- /dev/null
+++ b/src/main/resources/db/migration/V18__merge_request_assignee_reviewer.sql
@@ -0,0 +1,8 @@
1 +-- Optional assignee and reviewer for a merge request. Both nullable (unassigned by default) and, unlike
2 +-- author_id, set to null rather than cascade-deleted when the referenced user goes away, so the MR survives.
3 +ALTER TABLE merge_requests
4 + ADD COLUMN assignee_id uuid references users (id) on delete set null,
5 + ADD COLUMN reviewer_id uuid references users (id) on delete set null;
6 +
7 +create index merge_requests_assignee_idx on merge_requests (assignee_id);
8 +create index merge_requests_reviewer_idx on merge_requests (reviewer_id);
MODIFY src/main/resources/templates/MergeRequestResource/mergeRequest.html +83 -0
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequest.html b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
index f561783..1b32289 100644
--- a/src/main/resources/templates/MergeRequestResource/mergeRequest.html
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
@@ -13,6 +13,89 @@
13 13 {#if mr.description}
14 14 <pre class="issue-desc">{mr.description}</pre>
15 15 {/if}
16 +<div class="mr-meta">
17 + <div class="mr-meta-field">
18 + <h3 class="issue-side-label">Assignee</h3>
19 + {#if owner}
20 + <details class="assignee-pop">
21 + <summary class="assignee-trigger">
22 + {#if mr.assignee}{#avatar user=mr.assignee /} <span class="assignee-name">{mr.assignee.username}</span>{#else}<span class="muted">No one assigned</span>{/if}
23 + <span class="assignee-caret" aria-hidden="true">&#9662;</span>
24 + </summary>
25 + <div class="assignee-menu">
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">
28 + <input type="text" name="assignee" list="mr-people" placeholder="Type a username&hellip;" autocomplete="off">
29 + </form>
30 + <ul class="assignee-options">
31 + {#for candidate in assignees}
32 + <li>
33 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/assign">
34 + <button type="submit" name="assignee" value="{candidate.username}"
35 + class="assignee-option{#if mr.assignee && candidate.username == mr.assignee.username} is-selected{/if}">
36 + <span class="assignee-check" aria-hidden="true">{#if mr.assignee && candidate.username == mr.assignee.username}&check;{/if}</span>
37 + {#avatar user=candidate /} <span>{candidate.username}</span>
38 + </button>
39 + </form>
40 + </li>
41 + {/for}
42 + </ul>
43 + {#if mr.assignee}
44 + <form class="assignee-clear" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/assign">
45 + <button type="submit" class="assignee-option assignee-clear-btn">Clear assignee</button>
46 + </form>
47 + {/if}
48 + </div>
49 + </details>
50 + {#else}
51 + <div class="assignee-current">{#if mr.assignee}{#avatar user=mr.assignee /} <span class="assignee-name">{mr.assignee.username}</span>{#else}<span class="muted">No one assigned</span>{/if}</div>
52 + {/if}
53 + </div>
54 +
55 + <div class="mr-meta-field">
56 + <h3 class="issue-side-label">Reviewer</h3>
57 + {#if owner}
58 + <details class="assignee-pop">
59 + <summary class="assignee-trigger">
60 + {#if mr.reviewer}{#avatar user=mr.reviewer /} <span class="assignee-name">{mr.reviewer.username}</span>{#else}<span class="muted">No reviewer</span>{/if}
61 + <span class="assignee-caret" aria-hidden="true">&#9662;</span>
62 + </summary>
63 + <div class="assignee-menu">
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">
66 + <input type="text" name="reviewer" list="mr-people" placeholder="Type a username&hellip;" autocomplete="off">
67 + </form>
68 + <ul class="assignee-options">
69 + {#for candidate in assignees}
70 + <li>
71 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/reviewer">
72 + <button type="submit" name="reviewer" value="{candidate.username}"
73 + class="assignee-option{#if mr.reviewer && candidate.username == mr.reviewer.username} is-selected{/if}">
74 + <span class="assignee-check" aria-hidden="true">{#if mr.reviewer && candidate.username == mr.reviewer.username}&check;{/if}</span>
75 + {#avatar user=candidate /} <span>{candidate.username}</span>
76 + </button>
77 + </form>
78 + </li>
79 + {/for}
80 + </ul>
81 + {#if mr.reviewer}
82 + <form class="assignee-clear" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/reviewer">
83 + <button type="submit" class="assignee-option assignee-clear-btn">Clear reviewer</button>
84 + </form>
85 + {/if}
86 + </div>
87 + </details>
88 + {#else}
89 + <div class="assignee-current">{#if mr.reviewer}{#avatar user=mr.reviewer /} <span class="assignee-name">{mr.reviewer.username}</span>{#else}<span class="muted">No reviewer</span>{/if}</div>
90 + {/if}
91 + </div>
92 +
93 + <datalist id="mr-people">
94 + {#for candidate in assignees}
95 + <option value="{candidate.username}">
96 + {/for}
97 + </datalist>
98 +</div>
16 99 {#if owner && mr.status.name() == 'OPEN'}
17 100 <div class="issue-actions">
18 101 <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/merge">
MODIFY src/test/java/de/workaround/api/MergeRequestApiTest.java +2 -0
diff --git a/src/test/java/de/workaround/api/MergeRequestApiTest.java b/src/test/java/de/workaround/api/MergeRequestApiTest.java
index 0d4d060..72b0f00 100644
--- a/src/test/java/de/workaround/api/MergeRequestApiTest.java
+++ b/src/test/java/de/workaround/api/MergeRequestApiTest.java
@@ -52,6 +52,8 @@
52 52 .body("status", equalTo("OPEN"))
53 53 .body("sourceBranch", equalTo("feature"))
54 54 .body("targetBranch", equalTo("main"))
55 + .body("assignee", org.hamcrest.Matchers.nullValue())
56 + .body("reviewer", org.hamcrest.Matchers.nullValue())
55 57 .extract().path("number");
56 58
57 59 given().when().get(base)
MODIFY src/test/java/de/workaround/git/MergeRequestServiceTest.java +71 -0
diff --git a/src/test/java/de/workaround/git/MergeRequestServiceTest.java b/src/test/java/de/workaround/git/MergeRequestServiceTest.java
index b700640..e61acca 100644
--- a/src/test/java/de/workaround/git/MergeRequestServiceTest.java
+++ b/src/test/java/de/workaround/git/MergeRequestServiceTest.java
@@ -136,6 +136,77 @@
136 136 }
137 137
138 138 @Test
139 + void newMergeRequestsHaveNoAssigneeOrReviewer() throws Exception
140 + {
141 + User owner = persistUser("mr-omar");
142 + Repository repository = seed(owner, "mro");
143 +
144 + MergeRequest mr = mergeRequests.create(owner, repository, "x", null, "feature", "main");
145 +
146 + assertNull(mr.assignee, "a fresh merge request is unassigned");
147 + assertNull(mr.reviewer, "a fresh merge request has no reviewer");
148 + }
149 +
150 + @Test
151 + void assigneeAndReviewerAreSetByUsername() throws Exception
152 + {
153 + User owner = persistUser("mr-pat");
154 + User helper = persistUser("mr-quinn");
155 + Repository repository = seed(owner, "mrp");
156 + MergeRequest mr = mergeRequests.create(owner, repository, "x", null, "feature", "main");
157 +
158 + mergeRequests.assign(owner, mr, helper.username);
159 + mergeRequests.setReviewer(owner, mr, helper.username);
160 +
161 + // read once after the mutations (this class commits, so avoid a stale first-level-cache read)
162 + MergeRequest set = repo.findById(mr.id);
163 + assertEquals(helper.id, set.assignee.id, "the named user becomes the assignee");
164 + assertEquals(helper.id, set.reviewer.id, "the named user becomes the reviewer");
165 + }
166 +
167 + @Test
168 + void blankOrNullUsernameClearsAssigneeAndReviewer() throws Exception
169 + {
170 + User owner = persistUser("mr-uma");
171 + User helper = persistUser("mr-vic");
172 + Repository repository = seed(owner, "mru");
173 + MergeRequest mr = mergeRequests.create(owner, repository, "x", null, "feature", "main");
174 + mergeRequests.assign(owner, mr, helper.username);
175 + mergeRequests.setReviewer(owner, mr, helper.username);
176 +
177 + mergeRequests.assign(owner, mr, " ");
178 + mergeRequests.setReviewer(owner, mr, null);
179 +
180 + MergeRequest cleared = repo.findById(mr.id);
181 + assertNull(cleared.assignee, "a blank username unassigns");
182 + assertNull(cleared.reviewer, "a null username clears the reviewer");
183 + }
184 +
185 + @Test
186 + void assignAndSetReviewerRejectUnknownUsername() throws Exception
187 + {
188 + User owner = persistUser("mr-rob");
189 + Repository repository = seed(owner, "mrr");
190 + MergeRequest mr = mergeRequests.create(owner, repository, "x", null, "feature", "main");
191 +
192 + assertThrows(InvalidMergeRequestException.class, () -> mergeRequests.assign(owner, mr, "ghost-user"));
193 + assertThrows(InvalidMergeRequestException.class, () -> mergeRequests.setReviewer(owner, mr, "ghost-user"));
194 + }
195 +
196 + @Test
197 + void nonWriterCannotAssignOrSetReviewer() throws Exception
198 + {
199 + User owner = persistUser("mr-sam");
200 + User stranger = persistUser("mr-tom");
201 + Repository repository = seed(owner, "mrs");
202 + MergeRequest mr = mergeRequests.create(owner, repository, "x", null, "feature", "main");
203 +
204 + assertThrows(ForbiddenOperationException.class, () -> mergeRequests.assign(stranger, mr, stranger.username));
205 + assertThrows(ForbiddenOperationException.class,
206 + () -> mergeRequests.setReviewer(stranger, mr, stranger.username));
207 + }
208 +
209 + @Test
139 210 void countOpenExcludesMergedAndClosed() throws Exception
140 211 {
141 212 User owner = persistUser("mr-jane");
MODIFY src/test/java/de/workaround/web/MergeRequestUiTest.java +69 -0
diff --git a/src/test/java/de/workaround/web/MergeRequestUiTest.java b/src/test/java/de/workaround/web/MergeRequestUiTest.java
index e6bce3e..fde7405 100644
--- a/src/test/java/de/workaround/web/MergeRequestUiTest.java
+++ b/src/test/java/de/workaround/web/MergeRequestUiTest.java
@@ -12,6 +12,7 @@
12 12
13 13 import de.workaround.git.GitRepositoryService;
14 14 import de.workaround.git.GitTestSeeder;
15 +import de.workaround.git.MergeRequestService;
15 16 import de.workaround.model.Repository;
16 17 import de.workaround.model.User;
17 18 import io.quarkus.test.junit.QuarkusTest;
@@ -30,6 +31,9 @@
30 31 GitRepositoryService service;
31 32
32 33 @Inject
34 + MergeRequestService mergeRequestService;
35 +
36 + @Inject
33 37 User.Repo userRepo;
34 38
35 39 @Test
@@ -103,6 +107,71 @@
103 107 }
104 108
105 109 @Test
110 + @TestSecurity(user = "mru-assign")
111 + void assigneeAndReviewerCanBeSetAndClearedViaTheForm()
112 + {
113 + User owner = persistUser("mru-assign");
114 + User helper = persistUser("mru-helper");
115 + seed(owner, "assignboard");
116 + String base = "/repos/" + owner.username + "/assignboard/merge-requests";
117 + String location = given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
118 + .formParam("title", "Assign me").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
119 + .when().post(base).then().statusCode(303).extract().header("Location");
120 +
121 + // the detail page offers assignee and reviewer pickers
122 + given().when().get(location)
123 + .then().statusCode(200)
124 + .body(containsString("class=\"assignee-pop\""))
125 + .body(containsString("Reviewer"));
126 +
127 + // assign a person via the form
128 + given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
129 + .formParam("assignee", helper.username)
130 + .when().post(location + "/assign").then().statusCode(303);
131 + given().when().get(location).then().statusCode(200)
132 + .body(containsString("class=\"assignee-name\"")).body(containsString(helper.username));
133 +
134 + // set a reviewer via the form
135 + given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
136 + .formParam("reviewer", helper.username)
137 + .when().post(location + "/reviewer").then().statusCode(303);
138 +
139 + // clearing the assignee (blank) removes it again
140 + given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
141 + .formParam("assignee", "")
142 + .when().post(location + "/assign").then().statusCode(303);
143 + given().when().get(location).then().statusCode(200).body(containsString("No one assigned"));
144 + }
145 +
146 + @Test
147 + @TestSecurity(user = "mru-badassign")
148 + void assigningAnUnknownUsernameIsRejected()
149 + {
150 + User owner = persistUser("mru-badassign");
151 + seed(owner, "badassignboard");
152 + String base = "/repos/" + owner.username + "/badassignboard/merge-requests";
153 + String location = given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
154 + .formParam("title", "Bad assign").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
155 + .when().post(base).then().statusCode(303).extract().header("Location");
156 +
157 + given().contentType("application/x-www-form-urlencoded").formParam("assignee", "ghost-user")
158 + .when().post(location + "/assign").then().statusCode(400);
159 + }
160 +
161 + @Test
162 + void anonymousCannotAssignMergeRequests()
163 + {
164 + User owner = persistUser("mru-assign-anon-" + UUID.randomUUID().toString().substring(0, 8));
165 + Repository repo = seed(owner, "noassignboard");
166 + // create the MR as the owner (via the service) so an anonymous assign attempt has a target
167 + var mr = mergeRequestService.create(owner, repo, "x", null, "feature", "main");
168 +
169 + given().contentType("application/x-www-form-urlencoded").formParam("assignee", owner.username)
170 + .when().post("/repos/" + owner.username + "/noassignboard/merge-requests/" + mr.id + "/assign")
171 + .then().statusCode(403);
172 + }
173 +
174 + @Test
106 175 void anonymousCannotCreateMergeRequests()
107 176 {
108 177 User owner = persistUser("mru-anon-" + UUID.randomUUID().toString().substring(0, 8));

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog