✨ (dashboard): Surface involved issues and merge requests as notifications
Changes
11 files changed, +322 -9
MODIFY
README.md
+1 -0
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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