✨ (merge-requests): Assign an assignee and reviewer
Changes
11 files changed, +362 -6
MODIFY
README.md
+1 -1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">▾</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…" 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}✓{/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">▾</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…" 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}✓{/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
@@ -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
@@ -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
@@ -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));