gitshark

Clone repository

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

← Commits

✨ (merge-requests): Suggest top repo contributors in assignee/reviewer picker

dde4f9d4f106f06c28976115205f71affe240aa9 · Michael Hainz · 2026-07-20T11:09:18Z

Changes

6 files changed, +127 -3

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index 5e537be..dd406b3 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. 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
diff --git a/src/main/java/de/workaround/git/GitBrowseService.java b/src/main/java/de/workaround/git/GitBrowseService.java
index 77a1fac..4a3e4e3 100644
--- a/src/main/java/de/workaround/git/GitBrowseService.java
+++ b/src/main/java/de/workaround/git/GitBrowseService.java
@@ -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
diff --git a/src/main/java/de/workaround/model/User.java b/src/main/java/de/workaround/model/User.java
index e2a8871..a4d5954 100644
--- a/src/main/java/de/workaround/model/User.java
+++ b/src/main/java/de/workaround/model/User.java
@@ -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
diff --git a/src/main/java/de/workaround/web/MergeRequestResource.java b/src/main/java/de/workaround/web/MergeRequestResource.java
index d175e92..2ad532b 100644
--- a/src/main/java/de/workaround/web/MergeRequestResource.java
+++ b/src/main/java/de/workaround/web/MergeRequestResource.java
@@ -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
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index a932672..7d73ac7 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -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
diff --git a/src/test/java/de/workaround/web/MergeRequestUiTest.java b/src/test/java/de/workaround/web/MergeRequestUiTest.java
index e7ead35..b52bd5a 100644
--- a/src/test/java/de/workaround/web/MergeRequestUiTest.java
+++ b/src/test/java/de/workaround/web/MergeRequestUiTest.java
@@ -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 }

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog