gitshark

Clone repository

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

← Commits

✨ (dashboard): Surface involved issues and merge requests as notifications

6c6157d66bcc102e99578a7de4d8318a6ec73d88 · Phillip Souza Furtner · 2026-07-16T08:01:55Z

Changes

11 files changed, +322 -9

MODIFY README.md +1 -0
diff --git a/README.md b/README.md
index 1d3cf3b..0718444 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@
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 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 +- 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
28 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
29 30 - 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
30 31 - Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author, the repository owner, or a collaborator. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
MODIFY src/main/java/de/workaround/model/Issue.java +3 -0
diff --git a/src/main/java/de/workaround/model/Issue.java b/src/main/java/de/workaround/model/Issue.java
index c99c28e..9c1d44a 100644
--- a/src/main/java/de/workaround/model/Issue.java
+++ b/src/main/java/de/workaround/model/Issue.java
@@ -74,6 +74,9 @@
74 74 @HQL("select i from Issue i join fetch i.author left join fetch i.assignee where i.repository = :repository order by i.createdAt desc")
75 75 List<Issue> findByRepository(Repository repository);
76 76
77 + @HQL("select i from Issue i join fetch i.repository where (i.author = :user or i.assignee = :user) and i.status <> DONE order by i.createdAt desc")
78 + List<Issue> findOpenInvolving(User user);
79 +
77 80 @Find
78 81 Optional<Issue> findByRepositoryAndId(Repository repository, UUID id);
79 82
MODIFY src/main/java/de/workaround/model/MergeRequest.java +3 -0
diff --git a/src/main/java/de/workaround/model/MergeRequest.java b/src/main/java/de/workaround/model/MergeRequest.java
index c7c4ebd..374ff92 100644
--- a/src/main/java/de/workaround/model/MergeRequest.java
+++ b/src/main/java/de/workaround/model/MergeRequest.java
@@ -85,6 +85,9 @@
85 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")
86 86 List<MergeRequest> findByRepository(Repository repository);
87 87
88 + @HQL("select mr from MergeRequest mr join fetch mr.repository where (mr.author = :user or mr.assignee = :user or mr.reviewer = :user) and mr.status = OPEN order by mr.createdAt desc")
89 + List<MergeRequest> findOpenInvolving(User user);
90 +
88 91 @Find
89 92 Optional<MergeRequest> findByRepositoryAndId(Repository repository, UUID id);
90 93
ADD src/main/java/de/workaround/notify/IssueNotificationSource.java +38 -0
diff --git a/src/main/java/de/workaround/notify/IssueNotificationSource.java b/src/main/java/de/workaround/notify/IssueNotificationSource.java
new file mode 100644
index 0000000..1e2d087
--- /dev/null
+++ b/src/main/java/de/workaround/notify/IssueNotificationSource.java
@@ -0,0 +1,38 @@
1 +package de.workaround.notify;
2 +
3 +import java.util.List;
4 +
5 +import de.workaround.git.AccessPolicy;
6 +import de.workaround.model.Issue;
7 +import de.workaround.model.User;
8 +import jakarta.enterprise.context.ApplicationScoped;
9 +import jakarta.inject.Inject;
10 +
11 +/**
12 + * Surfaces open issues the user is involved in — ones they authored or are assigned to — as dashboard
13 + * notifications. Items in a repository the user can no longer read are dropped so a source turned
14 + * private never leaks through its issues.
15 + */
16 +@ApplicationScoped
17 +public class IssueNotificationSource implements NotificationSource
18 +{
19 + @Inject
20 + Issue.Repo issues;
21 +
22 + @Inject
23 + AccessPolicy accessPolicy;
24 +
25 + @Override
26 + public List<NotificationItem> notificationsFor(User user)
27 + {
28 + if (user == null)
29 + {
30 + return List.of();
31 + }
32 + return issues.findOpenInvolving(user).stream()
33 + .filter(issue -> accessPolicy.canRead(user, issue.repository))
34 + .map(issue -> new NotificationItem("issue", "#" + issue.number + " " + issue.title, issue.repository,
35 + "/repos/" + issue.repository.ownerHandle() + "/" + issue.repository.name + "/issues/" + issue.number))
36 + .toList();
37 + }
38 +}
ADD src/main/java/de/workaround/notify/MergeRequestNotificationSource.java +38 -0
diff --git a/src/main/java/de/workaround/notify/MergeRequestNotificationSource.java b/src/main/java/de/workaround/notify/MergeRequestNotificationSource.java
new file mode 100644
index 0000000..c727cd7
--- /dev/null
+++ b/src/main/java/de/workaround/notify/MergeRequestNotificationSource.java
@@ -0,0 +1,38 @@
1 +package de.workaround.notify;
2 +
3 +import java.util.List;
4 +
5 +import de.workaround.git.AccessPolicy;
6 +import de.workaround.model.MergeRequest;
7 +import de.workaround.model.User;
8 +import jakarta.enterprise.context.ApplicationScoped;
9 +import jakarta.inject.Inject;
10 +
11 +/**
12 + * Surfaces open merge requests the user is involved in — ones they authored, are assigned to, or are
13 + * asked to review — as dashboard notifications. Items in a repository the user can no longer read are
14 + * dropped so a source turned private never leaks through its merge requests.
15 + */
16 +@ApplicationScoped
17 +public class MergeRequestNotificationSource implements NotificationSource
18 +{
19 + @Inject
20 + MergeRequest.Repo mergeRequests;
21 +
22 + @Inject
23 + AccessPolicy accessPolicy;
24 +
25 + @Override
26 + public List<NotificationItem> notificationsFor(User user)
27 + {
28 + if (user == null)
29 + {
30 + return List.of();
31 + }
32 + return mergeRequests.findOpenInvolving(user).stream()
33 + .filter(mr -> accessPolicy.canRead(user, mr.repository))
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))
36 + .toList();
37 + }
38 +}
MODIFY src/main/java/de/workaround/notify/NotificationSource.java +2 -1
diff --git a/src/main/java/de/workaround/notify/NotificationSource.java b/src/main/java/de/workaround/notify/NotificationSource.java
index 5539735..11885fa 100644
--- a/src/main/java/de/workaround/notify/NotificationSource.java
+++ b/src/main/java/de/workaround/notify/NotificationSource.java
@@ -7,7 +7,8 @@
7 7 /**
8 8 * Contributes notification items for a user to the dashboard. Any feature (issues, merge requests, …)
9 9 * implements this as an {@code @ApplicationScoped} CDI bean; the {@link NotificationService} discovers
10 - * all implementations at runtime and aggregates their results. No concrete sources ship yet.
10 + * all implementations at runtime and aggregates their results. Issues and merge requests ship as
11 + * concrete sources.
11 12 */
12 13 public interface NotificationSource
13 14 {
MODIFY src/main/resources/templates/HomeResource/dashboard.html +5 -4
diff --git a/src/main/resources/templates/HomeResource/dashboard.html b/src/main/resources/templates/HomeResource/dashboard.html
index ed73198..8cc983a 100644
--- a/src/main/resources/templates/HomeResource/dashboard.html
+++ b/src/main/resources/templates/HomeResource/dashboard.html
@@ -45,14 +45,15 @@
45 45 <section class="dashboard-section">
46 46 <h2>Notifications</h2>
47 47 {#if notifications.isEmpty()}
48 - <p class="muted empty-state">No notifications — assigned issues and merge requests will appear here once those features land.</p>
48 + <p class="muted empty-state">Nothing needs your attention — issues and merge requests you're involved in show up here.</p>
49 49 {#else}
50 50 <table>
51 - <tr><th>Type</th><th>What</th></tr>
51 + <tr><th>Type</th><th>What</th><th>Repository</th></tr>
52 52 {#for item in notifications}
53 - <tr>
53 + <tr class="row-link">
54 54 <td><span class="badge">{item.category}</span></td>
55 - <td><a href="{item.targetUrl}">{item.title}</a></td>
55 + <td class="cell-link"><a href="{item.targetUrl}">{item.title}</a></td>
56 + <td class="muted">{#if item.repository}{item.repository.ownerHandle}/{item.repository.name}{/if}</td>
56 57 </tr>
57 58 {/for}
58 59 </table>
ADD src/test/java/de/workaround/notify/IssueNotificationSourceTest.java +104 -0
diff --git a/src/test/java/de/workaround/notify/IssueNotificationSourceTest.java b/src/test/java/de/workaround/notify/IssueNotificationSourceTest.java
new file mode 100644
index 0000000..dd49281
--- /dev/null
+++ b/src/test/java/de/workaround/notify/IssueNotificationSourceTest.java
@@ -0,0 +1,104 @@
1 +package de.workaround.notify;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.model.Issue;
9 +import de.workaround.model.Repository;
10 +import de.workaround.model.User;
11 +import io.quarkus.test.TestTransaction;
12 +import io.quarkus.test.junit.QuarkusTest;
13 +import jakarta.inject.Inject;
14 +
15 +import static org.junit.jupiter.api.Assertions.assertEquals;
16 +import static org.junit.jupiter.api.Assertions.assertTrue;
17 +
18 +@QuarkusTest
19 +class IssueNotificationSourceTest
20 +{
21 + @Inject
22 + IssueNotificationSource source;
23 +
24 + private int nextNumber = 1;
25 +
26 + @Test
27 + @TestTransaction
28 + void surfacesOpenIssuesTheUserAuthoredOrIsAssignedTo()
29 + {
30 + User user = persistUser("notif-issue-me");
31 + User other = persistUser("notif-issue-other");
32 + Repository repo = persistRepo(other, "nir1", Repository.Visibility.PUBLIC);
33 +
34 + Issue authored = persistIssue(repo, user, null, "I opened this", Issue.Status.PLANNED);
35 + Issue assigned = persistIssue(repo, other, user, "Assigned to me", Issue.Status.IN_DEVELOPMENT);
36 + persistIssue(repo, other, other, "Not mine", Issue.Status.PLANNED);
37 +
38 + List<NotificationItem> items = source.notificationsFor(user);
39 +
40 + assertEquals(2, items.size(), "only the authored and assigned issues involve the user");
41 + assertTrue(items.stream().allMatch(i -> i.category().equals("issue")));
42 + assertTrue(items.stream().anyMatch(i -> i.title().contains("I opened this")));
43 + assertTrue(items.stream().anyMatch(i -> i.title().contains("Assigned to me")));
44 + assertTrue(items.stream().anyMatch(i -> i.targetUrl().equals(
45 + "/repos/" + other.username + "/nir1/issues/" + authored.number)));
46 + assertTrue(items.stream().anyMatch(i -> i.targetUrl().endsWith("/issues/" + assigned.number)));
47 + }
48 +
49 + @Test
50 + @TestTransaction
51 + void excludesDoneIssues()
52 + {
53 + User user = persistUser("notif-issue-done");
54 + Repository repo = persistRepo(user, "nir2", Repository.Visibility.PUBLIC);
55 + persistIssue(repo, user, user, "Finished", Issue.Status.DONE);
56 +
57 + assertTrue(source.notificationsFor(user).isEmpty(), "DONE issues are not pending attention");
58 + }
59 +
60 + @Test
61 + @TestTransaction
62 + void excludesIssuesInRepositoriesTheUserCannotRead()
63 + {
64 + User user = persistUser("notif-issue-hidden");
65 + User owner = persistUser("notif-issue-priv-owner");
66 + Repository secret = persistRepo(owner, "nir3", Repository.Visibility.PRIVATE);
67 + // assigned to the user, but in a private repo they have no read grant to
68 + persistIssue(secret, owner, user, "Secret", Issue.Status.PLANNED);
69 +
70 + assertTrue(source.notificationsFor(user).isEmpty(), "a private repo the user cannot read must not leak");
71 + }
72 +
73 + private Issue persistIssue(Repository repo, User author, User assignee, String title, Issue.Status status)
74 + {
75 + Issue issue = new Issue();
76 + issue.repository = repo;
77 + issue.author = author;
78 + issue.assignee = assignee;
79 + issue.title = title;
80 + issue.status = status;
81 + issue.number = nextNumber++;
82 + issue.persist();
83 + return issue;
84 + }
85 +
86 + private Repository persistRepo(User owner, String name, Repository.Visibility visibility)
87 + {
88 + Repository repo = new Repository();
89 + repo.name = name;
90 + repo.ownerUser = owner;
91 + repo.visibility = visibility;
92 + repo.persist();
93 + return repo;
94 + }
95 +
96 + private User persistUser(String name)
97 + {
98 + User user = new User();
99 + user.oidcSub = name + "-" + UUID.randomUUID();
100 + user.username = name + "-" + UUID.randomUUID().toString().substring(0, 8);
101 + user.persist();
102 + return user;
103 + }
104 +}
ADD src/test/java/de/workaround/notify/MergeRequestNotificationSourceTest.java +108 -0
diff --git a/src/test/java/de/workaround/notify/MergeRequestNotificationSourceTest.java b/src/test/java/de/workaround/notify/MergeRequestNotificationSourceTest.java
new file mode 100644
index 0000000..a9fbb39
--- /dev/null
+++ b/src/test/java/de/workaround/notify/MergeRequestNotificationSourceTest.java
@@ -0,0 +1,108 @@
1 +package de.workaround.notify;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.model.MergeRequest;
9 +import de.workaround.model.Repository;
10 +import de.workaround.model.User;
11 +import io.quarkus.test.TestTransaction;
12 +import io.quarkus.test.junit.QuarkusTest;
13 +import jakarta.inject.Inject;
14 +
15 +import static org.junit.jupiter.api.Assertions.assertEquals;
16 +import static org.junit.jupiter.api.Assertions.assertTrue;
17 +
18 +@QuarkusTest
19 +class MergeRequestNotificationSourceTest
20 +{
21 + @Inject
22 + MergeRequestNotificationSource source;
23 +
24 + private int nextNumber = 1;
25 +
26 + @Test
27 + @TestTransaction
28 + void surfacesOpenMergeRequestsTheUserAuthoredIsAssignedToOrReviews()
29 + {
30 + User user = persistUser("notif-mr-me");
31 + User other = persistUser("notif-mr-other");
32 + Repository repo = persistRepo(other, "nmr1", Repository.Visibility.PUBLIC);
33 +
34 + persistMr(repo, user, null, null, "I opened this", MergeRequest.Status.OPEN);
35 + persistMr(repo, other, user, null, "Assigned to me", MergeRequest.Status.OPEN);
36 + persistMr(repo, other, null, user, "Review requested", MergeRequest.Status.OPEN);
37 + persistMr(repo, other, other, other, "Not mine", MergeRequest.Status.OPEN);
38 +
39 + List<NotificationItem> items = source.notificationsFor(user);
40 +
41 + assertEquals(3, items.size(), "authored, assigned and to-review MRs involve the user");
42 + assertTrue(items.stream().allMatch(i -> i.category().equals("merge-request")));
43 + assertTrue(items.stream().anyMatch(i -> i.title().contains("I opened this")));
44 + assertTrue(items.stream().anyMatch(i -> i.title().contains("Assigned to me")));
45 + assertTrue(items.stream().anyMatch(i -> i.title().contains("Review requested")));
46 + assertTrue(items.stream().allMatch(i -> i.targetUrl().contains("/merge-requests/")));
47 + }
48 +
49 + @Test
50 + @TestTransaction
51 + void excludesMergedAndClosedMergeRequests()
52 + {
53 + User user = persistUser("notif-mr-done");
54 + Repository repo = persistRepo(user, "nmr2", Repository.Visibility.PUBLIC);
55 + persistMr(repo, user, user, user, "Merged", MergeRequest.Status.MERGED);
56 + persistMr(repo, user, user, user, "Closed", MergeRequest.Status.CLOSED);
57 +
58 + assertTrue(source.notificationsFor(user).isEmpty(), "only open merge requests are pending attention");
59 + }
60 +
61 + @Test
62 + @TestTransaction
63 + void excludesMergeRequestsInRepositoriesTheUserCannotRead()
64 + {
65 + User user = persistUser("notif-mr-hidden");
66 + User owner = persistUser("notif-mr-priv-owner");
67 + Repository secret = persistRepo(owner, "nmr3", Repository.Visibility.PRIVATE);
68 + persistMr(secret, owner, user, user, "Secret", MergeRequest.Status.OPEN);
69 +
70 + assertTrue(source.notificationsFor(user).isEmpty(), "a private repo the user cannot read must not leak");
71 + }
72 +
73 + private MergeRequest persistMr(Repository repo, User author, User assignee, User reviewer, String title,
74 + MergeRequest.Status status)
75 + {
76 + MergeRequest mr = new MergeRequest();
77 + mr.repository = repo;
78 + mr.author = author;
79 + mr.assignee = assignee;
80 + mr.reviewer = reviewer;
81 + mr.title = title;
82 + mr.sourceBranch = "feature";
83 + mr.targetBranch = "main";
84 + mr.status = status;
85 + mr.number = nextNumber++;
86 + mr.persist();
87 + return mr;
88 + }
89 +
90 + private Repository persistRepo(User owner, String name, Repository.Visibility visibility)
91 + {
92 + Repository repo = new Repository();
93 + repo.name = name;
94 + repo.ownerUser = owner;
95 + repo.visibility = visibility;
96 + repo.persist();
97 + return repo;
98 + }
99 +
100 + private User persistUser(String name)
101 + {
102 + User user = new User();
103 + user.oidcSub = name + "-" + UUID.randomUUID();
104 + user.username = name + "-" + UUID.randomUUID().toString().substring(0, 8);
105 + user.persist();
106 + return user;
107 + }
108 +}
MODIFY src/test/java/de/workaround/notify/NotificationServiceTest.java +1 -2
diff --git a/src/test/java/de/workaround/notify/NotificationServiceTest.java b/src/test/java/de/workaround/notify/NotificationServiceTest.java
index 310ef23..c6cebfe 100644
--- a/src/test/java/de/workaround/notify/NotificationServiceTest.java
+++ b/src/test/java/de/workaround/notify/NotificationServiceTest.java
@@ -18,9 +18,8 @@
18 18 NotificationService service;
19 19
20 20 @Test
21 - void returnsEmptyWhenNoSourcesAreRegistered()
21 + void returnsEmptyForAUserInvolvedInNothing()
22 22 {
23 - // No concrete NotificationSource beans ship with this change.
24 23 assertTrue(service.notificationsFor(new User()).isEmpty());
25 24 }
26 25
MODIFY src/test/java/de/workaround/web/DashboardTest.java +19 -2
diff --git a/src/test/java/de/workaround/web/DashboardTest.java b/src/test/java/de/workaround/web/DashboardTest.java
index 8180bb9..73abb3f 100644
--- a/src/test/java/de/workaround/web/DashboardTest.java
+++ b/src/test/java/de/workaround/web/DashboardTest.java
@@ -24,6 +24,9 @@
24 24 GitRepositoryService service;
25 25
26 26 @Inject
27 + de.workaround.git.IssueService issueService;
28 +
29 + @Inject
27 30 User.Repo userRepo;
28 31
29 32 @Test
@@ -40,8 +43,22 @@
40 43 .body(containsString("Pinned"))
41 44 .body(containsString("Notifications"))
42 45 .body(containsString("All repositories"))
43 - // notifications empty state references the not-yet-built features
44 - .body(containsString("assigned issues and merge requests"));
46 + // notifications empty state when the user is not involved in anything yet
47 + .body(containsString("Nothing needs your attention"));
48 + }
49 +
50 + @Test
51 + @TestSecurity(user = "dash-notif")
52 + void issuesTheUserIsInvolvedInAppearInNotifications()
53 + {
54 + User user = persistUser("dash-notif");
55 + Repository repo = service.create(user, "notifrepo", Repository.Visibility.PUBLIC, null);
56 + issueService.create(user, repo, "Please look at this", null);
57 +
58 + given().when().get("/").then().statusCode(200)
59 + .body(containsString("Notifications"))
60 + .body(containsString("Please look at this"))
61 + .body(not(containsString("Nothing needs your attention")));
45 62 }
46 63
47 64 @Test

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog