✨ (merge-requests): Suggest top repo contributors in assignee/reviewer picker
Changes
6 files changed, +127 -3
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' `#`), 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
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 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
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/git/GitBrowseService.java
+43 -0
@@ -6,7 +6,10 @@
6
6
import java.time.Instant;
7
7
import java.util.ArrayList;
8
8
import java.util.Comparator;
9
+import java.util.HashMap;
9
10
import java.util.List;
11
+import java.util.Locale;
12
+import java.util.Map;
10
13
import java.util.Optional;
11
14
12
15
import org.eclipse.jgit.diff.RawText;
@@ -52,6 +55,10 @@
52
55
{
53
56
}
54
57
58
+ public record Contributor(String name, String email, int commits)
59
+ {
60
+ }
61
+
55
62
public boolean isEmpty(Path barePath)
56
63
{
57
64
try (Repository repo = open(barePath))
@@ -250,6 +257,42 @@
250
257
}
251
258
}
252
259
260
+ /**
261
+ * The most prolific commit authors on the given ref, most commits first (ties broken by email for a
262
+ * stable order). Authors are keyed by lower-cased email so one person committing under varying display
263
+ * names is counted once; the display name kept is the one seen on their newest commit reached first.
264
+ */
265
+ public List<Contributor> contributors(Path barePath, String ref, int limit)
266
+ {
267
+ try (Repository repo = open(barePath); RevWalk revWalk = new RevWalk(repo))
268
+ {
269
+ RevCommit start = resolveCommit(repo, revWalk, ref);
270
+ if (start == null)
271
+ {
272
+ return List.of();
273
+ }
274
+ revWalk.markStart(start);
275
+ Map<String, Integer> counts = new HashMap<>();
276
+ Map<String, String> names = new HashMap<>();
277
+ for (RevCommit commit : revWalk)
278
+ {
279
+ var ident = commit.getAuthorIdent();
280
+ String email = ident.getEmailAddress() == null ? "" : ident.getEmailAddress().toLowerCase(Locale.ROOT);
281
+ counts.merge(email, 1, Integer::sum);
282
+ names.putIfAbsent(email, ident.getName());
283
+ }
284
+ return counts.entrySet().stream()
285
+ .map(entry -> new Contributor(names.get(entry.getKey()), entry.getKey(), entry.getValue()))
286
+ .sorted(Comparator.comparingInt(Contributor::commits).reversed().thenComparing(Contributor::email))
287
+ .limit(limit)
288
+ .toList();
289
+ }
290
+ catch (IOException e)
291
+ {
292
+ throw new UncheckedIOException(e);
293
+ }
294
+ }
295
+
253
296
public List<BranchInfo> branches(Path barePath)
254
297
{
255
298
String defaultBranch = defaultBranch(barePath);
MODIFY
src/main/java/de/workaround/model/User.java
+5 -0
@@ -80,6 +80,11 @@
80
80
@Find
81
81
Optional<User> findByUsername(String username);
82
82
83
+ // Maps a commit author's email to a platform account (case-insensitive) so repository
84
+ // contributors can be surfaced as assignee/reviewer suggestions.
85
+ @HQL("where email is not null and lower(email) = lower(:email)")
86
+ Optional<User> findByEmailIgnoreCase(String email);
87
+
83
88
// Onboarded users only (username set); case-insensitive LIKE on handle and display name. The
84
89
// caller supplies the already-lowercased %pattern% so the wildcards live in one place.
85
90
@HQL("where username is not null and (lower(username) like :pattern or lower(displayName) like :pattern)"
MODIFY
src/main/java/de/workaround/web/MergeRequestResource.java
+42 -2
@@ -4,7 +4,9 @@
4
4
import java.nio.file.Path;
5
5
import java.util.ArrayList;
6
6
import java.util.List;
7
+import java.util.Set;
7
8
import java.util.UUID;
9
+import java.util.stream.Collectors;
8
10
9
11
import de.workaround.account.CurrentUser;
10
12
import de.workaround.git.AccessPolicy;
@@ -89,6 +91,9 @@
89
91
CollaboratorService collaboratorService;
90
92
91
93
@Inject
94
+ User.Repo userRepo;
95
+
96
+ @Inject
92
97
MergeRequestComment.Repo commentRepo;
93
98
94
99
@Inject
@@ -185,9 +190,14 @@
185
190
canModerate, mr, files, additions, deletions, assignableUsers(repo), discussion);
186
191
}
187
192
193
+ /** How many of a repository's top commit authors the pickers offer as suggestions. */
194
+ private static final int TOP_CONTRIBUTORS = 5;
195
+
188
196
/**
189
- * Suggestions offered by the assignee/reviewer pickers: the repository owner (for personal repos) plus
190
- * every collaborator. Assignment itself accepts any username; this list only powers the picker menus.
197
+ * Suggestions offered by the assignee/reviewer pickers: the repository owner (for personal repos), every
198
+ * collaborator, and the repository's most prolific commit authors — a reviewer is often best drawn from
199
+ * the people who actually wrote the code. Assignment itself accepts any username; this list only powers
200
+ * the picker menus.
191
201
*/
192
202
private List<User> assignableUsers(Repository repo)
193
203
{
@@ -197,9 +207,39 @@
197
207
users.add(repo.ownerUser);
198
208
}
199
209
collaboratorService.list(repo).forEach(collaborator -> users.add(collaborator.user));
210
+ appendTopContributors(repo, users);
200
211
return users;
201
212
}
202
213
214
+ /**
215
+ * Appends the repository's top commit authors whose email maps to a platform account and who are not
216
+ * already suggested. Only authors on the default branch are considered; empty repositories add nothing.
217
+ */
218
+ private void appendTopContributors(Repository repo, List<User> users)
219
+ {
220
+ Path path = service.repositoryPath(repo);
221
+ if (browse.isEmpty(path))
222
+ {
223
+ return;
224
+ }
225
+ Set<UUID> present = users.stream().map(user -> user.id).collect(Collectors.toSet());
226
+ for (GitBrowseService.Contributor contributor : browse.contributors(path, browse.defaultBranch(path),
227
+ TOP_CONTRIBUTORS))
228
+ {
229
+ if (contributor.email() == null || contributor.email().isBlank())
230
+ {
231
+ continue;
232
+ }
233
+ userRepo.findByEmailIgnoreCase(contributor.email()).ifPresent(user ->
234
+ {
235
+ if (user.username != null && present.add(user.id))
236
+ {
237
+ users.add(user);
238
+ }
239
+ });
240
+ }
241
+ }
242
+
203
243
/** Merge requests were originally addressed by UUID; keep old bookmarks and federated links working. */
204
244
@GET
205
245
@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}}")
MODIFY
src/main/resources/META-INF/resources/shark.css
+7 -0
@@ -943,6 +943,13 @@
943
943
word-break: break-word;
944
944
}
945
945
946
+/* the merge-request description is raw pre text; wrap long lines instead of forcing a
947
+ page-wide horizontal scrollbar */
948
+pre.issue-desc {
949
+ white-space: pre-wrap;
950
+ overflow-wrap: anywhere;
951
+}
952
+
946
953
.md-toggle {
947
954
display: flex;
948
955
gap: var(--s2);
MODIFY
src/test/java/de/workaround/web/MergeRequestUiTest.java
+29 -0
@@ -145,6 +145,26 @@
145
145
}
146
146
147
147
@Test
148
+ @TestSecurity(user = "mru-contrib")
149
+ void pickerSuggestsTopContributorsOfTheRepo()
150
+ {
151
+ User owner = persistUser("mru-contrib");
152
+ // a platform account whose email matches the commit author seeded on the default branch,
153
+ // but who is neither owner nor collaborator
154
+ User contributor = persistUserWithEmail("mru-topdev", "seed@example.com");
155
+ seed(owner, "contribboard");
156
+ String base = "/repos/" + owner.username + "/contribboard/merge-requests";
157
+ String location = given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
158
+ .formParam("title", "Suggest me").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
159
+ .when().post(base).then().statusCode(303).extract().header("Location");
160
+
161
+ // the picker suggests the top contributor even though they are not a collaborator
162
+ given().when().get(location).then().statusCode(200)
163
+ .body(containsString("value=\"" + contributor.username + "\""))
164
+ .body(containsString("name=\"reviewer\" value=\"" + contributor.username + "\""));
165
+ }
166
+
167
+ @Test
148
168
@TestSecurity(user = "mru-badassign")
149
169
void assigningAnUnknownUsernameIsRejected()
150
170
{
@@ -305,4 +325,13 @@
305
325
user.persist();
306
326
return user;
307
327
}
328
+
329
+ @Transactional
330
+ User persistUserWithEmail(String name, String email)
331
+ {
332
+ User user = persistUser(name);
333
+ user.email = email;
334
+ user.persist();
335
+ return user;
336
+ }
308
337
}