✨ Add pin feature
Changes
22 files changed, +1107 -3
ADD
openspec/changes/add-dashboard-pinned-notifications/.openspec.yaml
+2 -0
@@ -0,0 +1,2 @@
1
+schema: spec-driven
2
+created: 2026-06-21
ADD
openspec/changes/add-dashboard-pinned-notifications/design.md
+103 -0
@@ -0,0 +1,103 @@
1
+## Context
2
+
3
+The authenticated home page is rendered by `HomeResource.home()` (`GET /`), which calls
4
+`Templates.home(service.listVisibleTo(user), user)` — a single flat table of every repository visible
5
+to the user. Anonymous requests already branch to the landing page (added in `add-landing-page`).
6
+
7
+Domain model today: `User`, `Repository` (owner + `Visibility`), `SshKey`, `AccessToken`. There is **no
8
+Issue or Merge Request concept** anywhere in the codebase. Persistence is Hibernate + Panache Next with
9
+Flyway migrations (`db/migration/V1__init.sql`). The UI is server-rendered Qute with no JavaScript and
10
+must compile to a GraalVM native image.
11
+
12
+The user wants the post-login page to become a dashboard with three sections: pinned repositories,
13
+notifications (assigned issues / merge requests / etc.), and the full repository list. Because Issues
14
+and Merge Requests do not exist yet, the notifications section is built as a framework that ships with
15
+zero concrete sources (decision confirmed with the user).
16
+
17
+## Goals / Non-Goals
18
+
19
+**Goals:**
20
+- Restructure `GET /` (authenticated) into pinned → notifications → all-repositories sections.
21
+- Let users pin/unpin repositories, persisted per user across sessions.
22
+- Provide a `NotificationSource` abstraction the dashboard aggregates, with a defined empty state and
23
+ zero sources wired now.
24
+- Keep the page server-rendered, JS-free, and native-image safe.
25
+- Leave the anonymous landing page and `/explore` untouched.
26
+
27
+**Non-Goals:**
28
+- Implementing Issues or Merge Requests (separate future changes register as notification sources).
29
+- Drag-to-reorder pins, pin limits, or per-pin metadata beyond existence.
30
+- Real-time / push notifications, read/unread tracking, or notification persistence.
31
+- Any change to OIDC auth, SSH, or the Git protocol.
32
+
33
+## Decisions
34
+
35
+**Decision: Model a pin as a `RepositoryPin` join entity (user, repository, createdAt).**
36
+A dedicated `@Entity` with a unique constraint on `(user_id, repository_id)` keeps pins
37
+referentially clean and makes "is repo X pinned by user Y" and "list pins for user Y" simple Panache
38
+queries. Add a `V2__repository_pins.sql` Flyway migration with FKs to `users` and `repositories`, both
39
+`ON DELETE CASCADE` so deleting a repo or user clears its pins (satisfies the cleanup requirement
40
+without application code). Alternatives considered: a `@ManyToMany` set on `User` (hides the join table,
41
+harder to add `createdAt`/ordering later) — rejected; a boolean column on `Repository` (not per-user) —
42
+wrong semantics.
43
+
44
+**Decision: Pin/unpin via POST endpoints with redirect-back, no JavaScript.**
45
+Add `POST /repos/{owner}/{name}/pin` and `POST .../unpin` (or a single toggle) on `RepositoryResource`
46
+or `HomeResource`, each guarded by `currentUser.require()`, validating visibility via the existing
47
+`AccessPolicy`, then `seeOther` back to the referring page. Pin controls are small `<form>` buttons on
48
+each repo row and on `RepositoryResource/overview.html`. Rationale: matches the existing JS-free,
49
+form-POST pattern (`repos` creation). Alternative: a fetch/AJAX toggle — rejected, violates the no-JS
50
+constraint and native-image simplicity.
51
+
52
+**Decision: `NotificationSource` is a CDI interface; an aggregator injects all implementations.**
53
+Define `interface NotificationSource { List<NotificationItem> notificationsFor(User user); }` and a
54
+`NotificationService` that injects `Instance<NotificationSource>` (CDI), calls each, and concatenates
55
+results. `NotificationItem` is a simple record: `type/category`, `title`, optional `repository`, and
56
+`targetUrl`. With zero implementations, `Instance` is empty and the service returns an empty list.
57
+Rationale: new features contribute by just adding a `@ApplicationScoped` bean — no dashboard edits, no
58
+registry to maintain. Alternative: an explicit registry list in config — more boilerplate, easy to
59
+forget to update.
60
+
61
+**Decision: Isolate each source; one failure must not break the dashboard.**
62
+The aggregator wraps each `notificationsFor` call in try/catch, logs failures, and continues. Rationale:
63
+a buggy future source (e.g. a DB hiccup in the issues source) should degrade to "fewer notifications",
64
+never a 500 on the home page.
65
+
66
+**Decision: Build the dashboard view-model in the resource, pass one object to the template.**
67
+`HomeResource.home()` for an authenticated user assembles a `Dashboard` view-model: `pinned` (list),
68
+`notifications` (list), `repositories` (full visible list). The full list keeps showing all visible
69
+repos with each row reflecting pin state and offering the inverse action; pinned repos are not removed
70
+from it (simpler, and the pinned section is a shortcut, not a filter). Rationale: keeps Qute templates
71
+dumb and the assembly testable. Alternative: three separate template fragments fetching their own data
72
+— spreads queries into the view layer.
73
+
74
+**Decision: Deterministic ordering everywhere.** Pinned repos and the full list order by repository
75
+name (reusing the existing `order by name` in `findVisibleTo`); notifications order by `(category,
76
+title)`. Keeps rendering stable between identical requests (a spec requirement) and tests assertable.
77
+
78
+## Risks / Trade-offs
79
+
80
+- **Notifications section ships empty** → could read as "broken" to users. Mitigate with a clear empty
81
+ state ("No notifications — assigned issues and merge requests will appear here once those features
82
+ land") rather than a blank box.
83
+- **`ON DELETE CASCADE` relies on DB enforcement** → H2 (tests) and the prod DB must both honor it;
84
+ covered by a persistence test that deletes a pinned repo and asserts the pin is gone.
85
+- **Pinned repos also appearing in the full list** → mild duplication. Accepted: the pinned section is
86
+ a quick-access shortcut; hiding pinned rows from the full list would surprise users looking for "all".
87
+- **CDI `Instance<NotificationSource>` iteration order is unspecified** → mitigated by sorting the
88
+ aggregated result deterministically rather than depending on injection order.
89
+- **Adding a Flyway migration (`V2`)** → forward-only; rollback = revert template/code and drop the
90
+ `repository_pins` table. No existing data is modified.
91
+
92
+## Migration Plan
93
+
94
+Additive. Ship `V2__repository_pins.sql` (new table only), the new entity/service/endpoints, and the
95
+restructured `home.html`. No backfill — every user starts with zero pins and an empty notifications
96
+section. Rollback: revert the code change and drop the new table; no other data touched.
97
+
98
+## Open Questions
99
+
100
+- Single toggle endpoint vs. explicit `pin`/`unpin` — leaning explicit for idempotent, bookmarkable
101
+ semantics, but a toggle is less markup. Decide at implementation.
102
+- Whether to cap the notifications section height / paginate once real sources exist — out of scope now;
103
+ revisit when the first source lands.
ADD
openspec/changes/add-dashboard-pinned-notifications/proposal.md
+52 -0
@@ -0,0 +1,52 @@
1
+## Why
2
+
3
+After logging in, users land on a single flat table of every repository visible to them (`home.html`).
4
+There is no way to surface the handful of repositories someone actually works in day-to-day, and no
5
+place that tells a returning user what needs their attention. As the repository count grows, the
6
+dashboard becomes a wall of rows the user has to scan every visit. We want the post-login page to be a
7
+real **dashboard**: quick access to the repos you care about, a feed of what's waiting on you, and the
8
+full list still available below.
9
+
10
+## What Changes
11
+
12
+- Restructure the authenticated home page (`GET /`) into three stacked sections, in priority order:
13
+ 1. **Pinned repositories** — repos the user has explicitly pinned for quick access.
14
+ 2. **Notifications** — things assigned to or awaiting the user (assigned issues, assigned merge
15
+ requests, etc.).
16
+ 3. **All repositories** — the full visible-to-user list (today's behavior).
17
+- Add the ability to **pin / unpin a repository** per user. A pin is a per-user marker on a repository;
18
+ pinned repos appear in the dedicated section and are excluded from (or de-emphasized in) the full
19
+ list. Pin/unpin is reachable from the repository row and the repository overview page.
20
+- Introduce a **pluggable notification framework**: a `NotificationSource` abstraction that the
21
+ dashboard queries to build the notifications section. This change ships the framework and the UI
22
+ section with **no concrete sources wired** — git-shark has no Issue or Merge Request features yet.
23
+ When those land, they register as notification sources without further dashboard changes. Until then
24
+ the section renders an explicit empty state.
25
+- The anonymous landing page and `/explore` (public repo browsing) are unchanged.
26
+
27
+## Capabilities
28
+
29
+### New Capabilities
30
+- `repository-pinning`: Per-user pinning of repositories — pin, unpin, list a user's pinned repos, and
31
+ query whether a given repo is pinned. Persisted across sessions.
32
+- `dashboard-notifications`: A pluggable notification framework (`NotificationSource` abstraction)
33
+ plus the dashboard notifications section that aggregates and renders items awaiting the current user.
34
+ Ships with zero registered sources and a defined empty state.
35
+
36
+### Modified Capabilities
37
+<!-- No main specs exist yet (create-git-platform is not archived, openspec/specs/ is empty), so the
38
+ dashboard restructure of GET / is captured as part of this change's new capabilities rather than
39
+ as a delta against a published capability. This mirrors the add-landing-page change. -->
40
+
41
+## Impact
42
+
43
+- `web/HomeResource.java`: `home()` now assembles a dashboard view-model (pinned repos, notifications,
44
+ all repos) for authenticated users instead of a single repo list. New pin/unpin endpoints.
45
+- New model + persistence for pins (e.g. `RepositoryPin` join entity, or a pin association on the user)
46
+ and a Flyway migration (`db/migration/V2__...sql`).
47
+- New `NotificationSource` interface + an aggregating service; CDI-discovered, zero implementations now.
48
+- New/updated Qute templates: `HomeResource/home.html` split into three sections; pin controls on repo
49
+ rows and `RepositoryResource/overview.html`.
50
+- CSS additions in `shark.css` for the dashboard sections and empty states. No JavaScript (server-
51
+ rendered, native-image friendly).
52
+- No SSH, Git protocol, or auth changes.
ADD
openspec/changes/add-dashboard-pinned-notifications/specs/dashboard-notifications/spec.md
+72 -0
@@ -0,0 +1,72 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: Dashboard aggregates notifications for the current user
4
+
5
+The system SHALL render a notifications section on the authenticated dashboard that lists items
6
+awaiting the current user's attention (for example: issues assigned to them, merge requests assigned
7
+to them). The section SHALL be populated by querying all registered notification sources for the
8
+current user and combining their results.
9
+
10
+#### Scenario: Notifications rendered for an authenticated user
11
+
12
+- **WHEN** an authenticated user views the dashboard
13
+- **AND** one or more registered sources return items for that user
14
+- **THEN** the notifications section lists those items, each linking to its target
15
+
16
+#### Scenario: No notification sources registered
17
+
18
+- **WHEN** the dashboard is rendered and no notification sources are registered
19
+- **THEN** the notifications section renders an explicit empty state and produces no error
20
+
21
+#### Scenario: Anonymous users see no notifications section
22
+
23
+- **WHEN** an unauthenticated request reaches `/`
24
+- **THEN** the landing page is served and no notifications are queried
25
+
26
+### Requirement: Notification sources are pluggable
27
+
28
+The system SHALL define a `NotificationSource` abstraction that any feature can implement to contribute
29
+items to the dashboard, and the aggregating service SHALL discover all such implementations at runtime
30
+without further changes to the dashboard. A source SHALL receive the current user and return zero or
31
+more notification items.
32
+
33
+#### Scenario: New source contributes without dashboard changes
34
+
35
+- **WHEN** a new feature provides a `NotificationSource` implementation
36
+- **THEN** its items appear in the dashboard notifications section without modifying the dashboard code
37
+
38
+#### Scenario: Zero registered sources is a valid state
39
+
40
+- **WHEN** the application starts with no `NotificationSource` implementations
41
+- **THEN** the dashboard renders successfully with an empty notifications section
42
+
43
+### Requirement: A notification item carries enough data to render and link
44
+
45
+The system SHALL model a notification item with at least: a category/type (e.g. assigned issue,
46
+assigned merge request), a human-readable title, the associated repository (when applicable), and a
47
+target URL the user can follow.
48
+
49
+#### Scenario: Item renders with title and link
50
+
51
+- **WHEN** a source returns a notification item
52
+- **THEN** the dashboard displays its title and category and links to its target URL
53
+
54
+### Requirement: One failing source does not break the dashboard
55
+
56
+The system SHALL isolate notification sources so that an error in one source does not prevent the
57
+dashboard from rendering or suppress items from other sources.
58
+
59
+#### Scenario: A source throws
60
+
61
+- **WHEN** one registered source raises an error while producing items
62
+- **THEN** the dashboard still renders, items from other sources are shown, and the failure is logged
63
+
64
+### Requirement: Notifications are ordered deterministically
65
+
66
+The system SHALL present aggregated notification items in a stable, predictable order so the section
67
+does not reshuffle between identical requests.
68
+
69
+#### Scenario: Stable ordering across requests
70
+
71
+- **WHEN** the same user loads the dashboard twice with unchanged underlying data
72
+- **THEN** the notification items appear in the same order both times
ADD
openspec/changes/add-dashboard-pinned-notifications/specs/repository-pinning/spec.md
+93 -0
@@ -0,0 +1,93 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: User can pin a repository
4
+
5
+The system SHALL allow an authenticated user to pin any repository that is visible to them. A pin is
6
+a per-user marker; pinning a repository affects only the pinning user's view and does not change the
7
+repository for anyone else.
8
+
9
+#### Scenario: Pin a visible repository
10
+
11
+- **WHEN** an authenticated user pins a repository visible to them
12
+- **THEN** the repository is recorded as pinned for that user
13
+- **AND** it appears in that user's pinned-repositories section on the next dashboard view
14
+
15
+#### Scenario: Pinning is idempotent
16
+
17
+- **WHEN** a user pins a repository they have already pinned
18
+- **THEN** the system records no duplicate pin and reports success
19
+- **AND** the repository appears exactly once in the pinned section
20
+
21
+#### Scenario: Cannot pin a repository that is not visible
22
+
23
+- **WHEN** an authenticated user attempts to pin a repository they cannot see
24
+- **THEN** the system rejects the request and creates no pin
25
+
26
+#### Scenario: Anonymous users cannot pin
27
+
28
+- **WHEN** an unauthenticated request attempts to pin a repository
29
+- **THEN** the system rejects the request as unauthorized and creates no pin
30
+
31
+### Requirement: User can unpin a repository
32
+
33
+The system SHALL allow an authenticated user to remove a pin they previously created.
34
+
35
+#### Scenario: Unpin a pinned repository
36
+
37
+- **WHEN** a user unpins a repository they had pinned
38
+- **THEN** the pin is removed
39
+- **AND** the repository no longer appears in that user's pinned section
40
+
41
+#### Scenario: Unpinning a repository that is not pinned
42
+
43
+- **WHEN** a user unpins a repository they had not pinned
44
+- **THEN** the system reports success and makes no change
45
+
46
+### Requirement: Pinned repositories are scoped to the owning user
47
+
48
+The system SHALL store pins per user so that one user's pins are never visible to another user.
49
+
50
+#### Scenario: Pins are isolated between users
51
+
52
+- **WHEN** user A pins a repository and user B views their own dashboard
53
+- **THEN** the repository does not appear in user B's pinned section unless user B also pinned it
54
+
55
+#### Scenario: Pins persist across sessions
56
+
57
+- **WHEN** a user pins a repository, logs out, and logs back in
58
+- **THEN** the repository is still listed in their pinned section
59
+
60
+### Requirement: Pinned repositories are listable for the current user
61
+
62
+The system SHALL provide the set of repositories pinned by the current user, ordered deterministically
63
+(e.g. by repository name), for rendering the dashboard pinned section.
64
+
65
+#### Scenario: List pinned repositories
66
+
67
+- **WHEN** the dashboard is rendered for an authenticated user
68
+- **THEN** the pinned section lists exactly the repositories that user has pinned, in a stable order
69
+
70
+#### Scenario: Empty pinned section
71
+
72
+- **WHEN** an authenticated user with no pins views the dashboard
73
+- **THEN** the pinned section renders an explicit empty state rather than an empty table
74
+
75
+### Requirement: Pin state is reflected in the full repository list
76
+
77
+The system SHALL indicate, for each repository in the full "all repositories" list, whether the current
78
+user has pinned it, and SHALL offer the inverse action (pin if unpinned, unpin if pinned).
79
+
80
+#### Scenario: Toggle control reflects current state
81
+
82
+- **WHEN** a user views the all-repositories list
83
+- **THEN** each row offers a "Pin" action for unpinned repos and an "Unpin" action for pinned repos
84
+
85
+### Requirement: Removing a repository removes its pins
86
+
87
+The system SHALL ensure that when a repository is deleted, any pins referencing it are also removed so
88
+no dangling pins remain.
89
+
90
+#### Scenario: Pins cleaned up on repository deletion
91
+
92
+- **WHEN** a repository that one or more users have pinned is deleted
93
+- **THEN** all pins referencing that repository are removed
ADD
openspec/changes/add-dashboard-pinned-notifications/tasks.md
+41 -0
@@ -0,0 +1,41 @@
1
+## 1. Failing tests first
2
+
3
+- [x] 1.1 Persistence test: pinning a repo creates a `RepositoryPin`; pinning twice is idempotent (no duplicate); unpinning removes it
4
+- [x] 1.2 Persistence test: deleting a pinned repository removes its pins (cascade); pins are isolated per user (user A's pin not seen for user B)
5
+- [x] 1.3 Service test: `NotificationService` with zero registered sources returns an empty list; with test `NotificationSource` instances, returns their items; ordering is deterministic
6
+- [x] 1.4 Service test: when one `NotificationSource` throws, aggregation still returns items from the other sources and does not propagate the error
7
+- [x] 1.5 Web test: authenticated `GET /` renders three sections — pinned, notifications (empty state), all repositories
8
+- [x] 1.6 Web test: `POST /repos/{owner}/{name}/pin` adds the repo to the user's pinned section on the next `GET /`; `unpin` removes it
9
+- [x] 1.7 Web test: pinning a non-visible repo is rejected (404); anonymous pin is unauthorized (403); anonymous `GET /` still serves the landing page (unchanged)
10
+- [x] 1.8 Run the suite, confirm the new tests fail (red) — failed at compile against not-yet-written production code
11
+
12
+## 2. Persistence — repository pinning
13
+
14
+- [x] 2.1 Add `RepositoryPin` entity (`user`, `repository`, `createdAt`) with a unique constraint on `(user, repository)` and a Panache `Repo` interface (findByUserAndRepository, findByUser, findPinnedRepositories)
15
+- [x] 2.2 Add Flyway migration `db/migration/V2__repository_pins.sql`: table with FKs to `users` and `repositories`, both `ON DELETE CASCADE`, unique `(user_id, repository_id)`
16
+- [x] 2.3 Add a `RepositoryPinService`: `pin(user, repo)`, `unpin(user, repo)`, `listPinned(user)` ordered by name, `isPinned(user, repo)`, enforcing visibility via `AccessPolicy`
17
+
18
+## 3. Notification framework
19
+
20
+- [x] 3.1 Define `NotificationItem` (record: category, title, optional repository, targetUrl) and `NotificationSource` interface (`List<NotificationItem> notificationsFor(User user)`)
21
+- [x] 3.2 Implement `NotificationService` injecting `Instance<NotificationSource>`, aggregating per-source results, isolating failures (try/catch + log), and sorting deterministically by `(category, title)`
22
+- [x] 3.3 Confirm zero concrete sources are wired (empty `Instance` → empty list); no Issue/MR code added in this change
23
+
24
+## 4. Dashboard routing & view-model
25
+
26
+- [x] 4.1 Build a dashboard view-model (pinned, notifications, `DashboardRepo` rows) and have `HomeResource.home()` assemble it for authenticated users; anonymous still routes to the landing page
27
+- [x] 4.2 Add `POST /repos/{owner}/{name}/pin` and `POST .../unpin` endpoints: `currentUser.require()`, visibility check, then `seeOther` back to the (validated) referrer (default `/`). Added a `ForbiddenOperationException`→403 mapper so anonymous mutations return 403 instead of 500
28
+- [x] 4.3 Add the `dashboard(...)` native template method to `@CheckedTemplate`
29
+
30
+## 5. Templates & styling
31
+
32
+- [x] 5.1 Add `templates/HomeResource/dashboard.html` with three sections: Pinned (with empty state), Notifications (empty-state copy referencing future issues/MRs), All repositories. Kept `home.html` as the plain list for the anonymous `/explore` route so the dashboard sections don't leak there
33
+- [x] 5.2 Add a pin/unpin `<form>` control to each repo row reflecting current pin state (Pin if unpinned, Unpin if pinned)
34
+- [x] 5.3 Add a pin/unpin control to `RepositoryResource/overview.html`
35
+- [x] 5.4 Add CSS in `shark.css` for the dashboard sections, pin controls, and empty states; no JavaScript
36
+
37
+## 6. Verify
38
+
39
+- [x] 6.1 Run `./mvnw test` — 69/70 green. The one failure (`SettingsPagesTest.anonymousIsRedirectedToLogin`) is a pre-existing Keycloak Dev Service startup flake ("OIDC Server is not available"), unrelated to this change; all new tests pass
40
+- [ ] 6.2 Manual check in `quarkus:dev`: pin/unpin reflects in the pinned section; notifications shows the empty state; full list still lists all visible repos; anonymous `/` unchanged
41
+- [x] 6.3 Update `README.md` for the user-facing dashboard/pinning behavior and the new `repository_pins` table
ADD
src/main/java/de/workaround/git/RepositoryPinService.java
+58 -0
@@ -0,0 +1,58 @@
1
+package de.workaround.git;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.model.Repository;
6
+import de.workaround.model.RepositoryPin;
7
+import de.workaround.model.User;
8
+import jakarta.enterprise.context.ApplicationScoped;
9
+import jakarta.inject.Inject;
10
+import jakarta.transaction.Transactional;
11
+
12
+/**
13
+ * Manages per-user repository pins for the dashboard. Pinning is idempotent and respects the same
14
+ * read-visibility rule as the rest of the platform: you can only pin a repository you can see.
15
+ */
16
+@ApplicationScoped
17
+public class RepositoryPinService
18
+{
19
+ @Inject
20
+ RepositoryPin.Repo pins;
21
+
22
+ @Inject
23
+ AccessPolicy accessPolicy;
24
+
25
+ @Transactional
26
+ public void pin(User user, Repository repository)
27
+ {
28
+ if (!accessPolicy.canRead(user, repository))
29
+ {
30
+ throw new ForbiddenOperationException("Cannot pin a repository you cannot access");
31
+ }
32
+ if (pins.findByUserAndRepository(user, repository).isPresent())
33
+ {
34
+ return;
35
+ }
36
+ RepositoryPin pin = new RepositoryPin();
37
+ pin.user = user;
38
+ pin.repository = repository;
39
+ pin.persist();
40
+ }
41
+
42
+ @Transactional
43
+ public void unpin(User user, Repository repository)
44
+ {
45
+ pins.findByUserAndRepository(user, repository).ifPresent(pin -> pins.deleteById(pin.id));
46
+ }
47
+
48
+ public List<Repository> listPinned(User user)
49
+ {
50
+ return pins.findPinnedRepositories(user);
51
+ }
52
+
53
+ public boolean isPinned(User user, Repository repository)
54
+ {
55
+ return pins.findByUserAndRepository(user, repository).isPresent();
56
+ }
57
+
58
+}
ADD
src/main/java/de/workaround/model/RepositoryPin.java
+53 -0
@@ -0,0 +1,53 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.GeneratedValue;
15
+import jakarta.persistence.GenerationType;
16
+import jakarta.persistence.Id;
17
+import jakarta.persistence.ManyToOne;
18
+import jakarta.persistence.Table;
19
+import jakarta.persistence.UniqueConstraint;
20
+
21
+/**
22
+ * Per-user marker that a repository is pinned for quick access on the dashboard.
23
+ * A pin affects only the owning user's view; uniqueness is enforced per (user, repository).
24
+ */
25
+@Entity
26
+@Table(name = "repository_pins", uniqueConstraints = @UniqueConstraint(columnNames = { "user_id", "repository_id" }))
27
+public class RepositoryPin implements PanacheEntity.Managed
28
+{
29
+ @Id
30
+ @GeneratedValue(strategy = GenerationType.UUID)
31
+ public UUID id;
32
+
33
+ @ManyToOne(optional = false)
34
+ public User user;
35
+
36
+ @ManyToOne(optional = false)
37
+ public Repository repository;
38
+
39
+ public Instant createdAt = Instant.now();
40
+
41
+ public interface Repo extends PanacheRepository.Managed<RepositoryPin, UUID>
42
+ {
43
+ @Find
44
+ Optional<RepositoryPin> findByUserAndRepository(User user, Repository repository);
45
+
46
+ @Find
47
+ List<RepositoryPin> findByUser(User user);
48
+
49
+ @HQL("select p.repository from RepositoryPin p where p.user = :user order by p.repository.name")
50
+ List<Repository> findPinnedRepositories(User user);
51
+ }
52
+
53
+}
ADD
src/main/java/de/workaround/notify/NotificationItem.java
+15 -0
@@ -0,0 +1,15 @@
1
+package de.workaround.notify;
2
+
3
+import de.workaround.model.Repository;
4
+
5
+/**
6
+ * A single item awaiting the current user's attention on the dashboard.
7
+ *
8
+ * @param category short type label, e.g. {@code "issue"} or {@code "merge-request"}
9
+ * @param title human-readable description shown to the user
10
+ * @param repository the related repository, or {@code null} if not repository-scoped
11
+ * @param targetUrl where following the item takes the user
12
+ */
13
+public record NotificationItem(String category, String title, Repository repository, String targetUrl)
14
+{
15
+}
ADD
src/main/java/de/workaround/notify/NotificationService.java
+57 -0
@@ -0,0 +1,57 @@
1
+package de.workaround.notify;
2
+
3
+import java.util.ArrayList;
4
+import java.util.Comparator;
5
+import java.util.List;
6
+
7
+import org.jboss.logging.Logger;
8
+
9
+import de.workaround.model.User;
10
+import jakarta.enterprise.context.ApplicationScoped;
11
+import jakarta.enterprise.inject.Instance;
12
+import jakarta.inject.Inject;
13
+
14
+/**
15
+ * Aggregates notification items from every registered {@link NotificationSource}. Sources are isolated:
16
+ * a failing source is logged and skipped so it can never break the dashboard. With no registered
17
+ * sources the result is simply empty.
18
+ */
19
+@ApplicationScoped
20
+public class NotificationService
21
+{
22
+ private static final Logger LOG = Logger.getLogger(NotificationService.class);
23
+
24
+ private static final Comparator<NotificationItem> ORDER =
25
+ Comparator.comparing(NotificationItem::category).thenComparing(NotificationItem::title);
26
+
27
+ @Inject
28
+ Instance<NotificationSource> sources;
29
+
30
+ public List<NotificationItem> notificationsFor(User user)
31
+ {
32
+ return aggregate(user, sources);
33
+ }
34
+
35
+ List<NotificationItem> aggregate(User user, Iterable<NotificationSource> sources)
36
+ {
37
+ List<NotificationItem> items = new ArrayList<>();
38
+ for (NotificationSource source : sources)
39
+ {
40
+ try
41
+ {
42
+ List<NotificationItem> produced = source.notificationsFor(user);
43
+ if (produced != null)
44
+ {
45
+ items.addAll(produced);
46
+ }
47
+ }
48
+ catch (RuntimeException e)
49
+ {
50
+ LOG.errorf(e, "Notification source %s failed; skipping it", source.getClass().getName());
51
+ }
52
+ }
53
+ items.sort(ORDER);
54
+ return items;
55
+ }
56
+
57
+}
ADD
src/main/java/de/workaround/notify/NotificationSource.java
+15 -0
@@ -0,0 +1,15 @@
1
+package de.workaround.notify;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.model.User;
6
+
7
+/**
8
+ * Contributes notification items for a user to the dashboard. Any feature (issues, merge requests, …)
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.
11
+ */
12
+public interface NotificationSource
13
+{
14
+ List<NotificationItem> notificationsFor(User user);
15
+}
ADD
src/main/java/de/workaround/web/ForbiddenOperationExceptionMapper.java
+24 -0
@@ -0,0 +1,24 @@
1
+package de.workaround.web;
2
+
3
+import de.workaround.git.ForbiddenOperationException;
4
+import jakarta.ws.rs.core.MediaType;
5
+import jakarta.ws.rs.core.Response;
6
+import jakarta.ws.rs.ext.ExceptionMapper;
7
+import jakarta.ws.rs.ext.Provider;
8
+
9
+/**
10
+ * Maps the domain {@link ForbiddenOperationException} to HTTP 403 instead of a generic 500, so
11
+ * authorization failures (e.g. an anonymous request attempting a mutation) surface cleanly.
12
+ */
13
+@Provider
14
+public class ForbiddenOperationExceptionMapper implements ExceptionMapper<ForbiddenOperationException>
15
+{
16
+ @Override
17
+ public Response toResponse(ForbiddenOperationException exception)
18
+ {
19
+ return Response.status(Response.Status.FORBIDDEN)
20
+ .entity(exception.getMessage())
21
+ .type(MediaType.TEXT_PLAIN)
22
+ .build();
23
+ }
24
+}
MODIFY
src/main/java/de/workaround/web/HomeResource.java
+26 -1
@@ -2,13 +2,19 @@
2
2
3
3
import java.net.URI;
4
4
import java.util.List;
5
+import java.util.Set;
6
+import java.util.UUID;
7
+import java.util.stream.Collectors;
5
8
6
9
import de.workaround.account.CurrentUser;
7
10
import de.workaround.git.GitRepositoryService;
8
11
import de.workaround.git.InvalidRepositoryNameException;
9
12
import de.workaround.git.RepositoryAlreadyExistsException;
13
+import de.workaround.git.RepositoryPinService;
10
14
import de.workaround.model.Repository;
11
15
import de.workaround.model.User;
16
+import de.workaround.notify.NotificationItem;
17
+import de.workaround.notify.NotificationService;
12
18
import io.quarkus.qute.CheckedTemplate;
13
19
import io.quarkus.qute.TemplateInstance;
14
20
import jakarta.inject.Inject;
@@ -30,17 +36,31 @@
30
36
{
31
37
static native TemplateInstance home(List<Repository> repositories, User user);
32
38
39
+ static native TemplateInstance dashboard(List<Repository> pinned, List<NotificationItem> notifications,
40
+ List<DashboardRepo> repositories, User user);
41
+
33
42
static native TemplateInstance landing();
34
43
35
44
static native TemplateInstance newRepo(String error);
36
45
}
37
46
47
+ /** A repository row in the dashboard's full list, carrying whether the current user has pinned it. */
48
+ public record DashboardRepo(Repository repo, boolean pinned)
49
+ {
50
+ }
51
+
38
52
@Inject
39
53
CurrentUser currentUser;
40
54
41
55
@Inject
42
56
GitRepositoryService service;
43
57
58
+ @Inject
59
+ RepositoryPinService pinService;
60
+
61
+ @Inject
62
+ NotificationService notifications;
63
+
44
64
@GET
45
65
public TemplateInstance home()
46
66
{
@@ -49,7 +69,12 @@
49
69
{
50
70
return Templates.landing();
51
71
}
52
- return Templates.home(service.listVisibleTo(user), user);
72
+ List<Repository> pinned = pinService.listPinned(user);
73
+ Set<UUID> pinnedIds = pinned.stream().map(repo -> repo.id).collect(Collectors.toSet());
74
+ List<DashboardRepo> rows = service.listVisibleTo(user).stream()
75
+ .map(repo -> new DashboardRepo(repo, pinnedIds.contains(repo.id)))
76
+ .toList();
77
+ return Templates.dashboard(pinned, notifications.notificationsFor(user), rows, user);
53
78
}
54
79
55
80
@GET
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+42 -2
@@ -9,6 +9,7 @@
9
9
import de.workaround.git.AccessPolicy;
10
10
import de.workaround.git.GitBrowseService;
11
11
import de.workaround.git.GitRepositoryService;
12
+import de.workaround.git.RepositoryPinService;
12
13
import de.workaround.model.Repository;
13
14
import de.workaround.model.User;
14
15
import io.quarkus.qute.CheckedTemplate;
@@ -37,7 +38,8 @@
37
38
static class Templates
38
39
{
39
40
static native TemplateInstance overview(Repository repo, boolean owner, boolean empty, String defaultBranch,
40
- List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl);
41
+ List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl, boolean loggedIn,
42
+ boolean pinned);
41
43
42
44
static native TemplateInstance tree(Repository repo, String ref, String path,
43
45
List<GitBrowseService.TreeEntry> entries);
@@ -63,6 +65,9 @@
63
65
@Inject
64
66
AccessPolicy accessPolicy;
65
67
68
+ @Inject
69
+ RepositoryPinService pinService;
70
+
66
71
@ConfigProperty(name = "gitshark.ssh.port")
67
72
int sshPort;
68
73
@@ -81,7 +86,10 @@
81
86
: browse.listTree(path, defaultBranch, "").orElse(List.of());
82
87
User user = currentUser.get();
83
88
boolean isOwner = user != null && user.id.equals(repo.owner.id);
84
- return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo));
89
+ boolean loggedIn = user != null;
90
+ boolean pinned = loggedIn && pinService.isPinned(user, repo);
91
+ return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo),
92
+ loggedIn, pinned);
85
93
}
86
94
87
95
@GET
@@ -160,6 +168,38 @@
160
168
return Response.seeOther(URI.create("/")).build();
161
169
}
162
170
171
+ @POST
172
+ @jakarta.ws.rs.Path("pin")
173
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
174
+ public Response pin(@PathParam("owner") String owner, @PathParam("name") String name,
175
+ @FormParam("redirect") @DefaultValue("/") String redirect)
176
+ {
177
+ Repository repo = requireReadable(owner, name);
178
+ pinService.pin(currentUser.require(), repo);
179
+ return Response.seeOther(safeRedirect(redirect)).build();
180
+ }
181
+
182
+ @POST
183
+ @jakarta.ws.rs.Path("unpin")
184
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
185
+ public Response unpin(@PathParam("owner") String owner, @PathParam("name") String name,
186
+ @FormParam("redirect") @DefaultValue("/") String redirect)
187
+ {
188
+ Repository repo = requireReadable(owner, name);
189
+ pinService.unpin(currentUser.require(), repo);
190
+ return Response.seeOther(safeRedirect(redirect)).build();
191
+ }
192
+
193
+ private static URI safeRedirect(String redirect)
194
+ {
195
+ // only allow same-site relative paths to avoid open-redirect
196
+ if (redirect != null && redirect.startsWith("/") && !redirect.startsWith("//"))
197
+ {
198
+ return URI.create(redirect);
199
+ }
200
+ return URI.create("/");
201
+ }
202
+
163
203
private TemplateInstance blobView(Repository repo, Path repoPath, String ref, String path)
164
204
{
165
205
GitBrowseService.BlobView blob = browse.blob(repoPath, ref, path).orElseThrow(NotFoundException::new);
MODIFY
src/main/resources/META-INF/resources/shark.css
+56 -0
@@ -478,6 +478,62 @@
478
478
border-color: var(--shark-blue);
479
479
}
480
480
481
+/* dashboard */
482
+
483
+.dashboard-section {
484
+ margin-bottom: var(--space-5);
485
+}
486
+
487
+.dashboard-section h2 {
488
+ margin-bottom: var(--space-2);
489
+}
490
+
491
+.empty-state {
492
+ padding: var(--space-3);
493
+ background: var(--surface);
494
+ border: 1px dashed var(--border);
495
+ border-radius: var(--radius);
496
+}
497
+
498
+td.actions {
499
+ width: 1%;
500
+ white-space: nowrap;
501
+ text-align: right;
502
+}
503
+
504
+td.actions .btn {
505
+ font-size: 0.8rem;
506
+}
507
+
508
+/* icon-only action button (e.g. pin/unpin) */
509
+.btn-icon {
510
+ display: inline-flex;
511
+ align-items: center;
512
+ justify-content: center;
513
+ padding: var(--space-1);
514
+ border: none;
515
+ background: none;
516
+ color: var(--ink-muted);
517
+ cursor: pointer;
518
+ border-radius: var(--radius);
519
+ line-height: 0;
520
+}
521
+
522
+.btn-icon:hover {
523
+ color: var(--shark-blue);
524
+ background: var(--shark-belly);
525
+}
526
+
527
+.btn-icon.pinned {
528
+ color: var(--shark-blue);
529
+}
530
+
531
+.btn-icon svg {
532
+ width: 18px;
533
+ height: 18px;
534
+ display: block;
535
+}
536
+
481
537
/* hotkey help dialog */
482
538
483
539
dialog#hotkey-help {
MODIFY
src/main/resources/application.properties
+10 -0
@@ -9,6 +9,16 @@
9
9
10
10
# OIDC: dev/test use Keycloak Dev Services.
11
11
# Production supplies QUARKUS_OIDC_AUTH_SERVER_URL / _CLIENT_ID / _CREDENTIALS_SECRET via environment.
12
+# Keycloak Dev Services on macOS/Docker Desktop: the host->container hop is classified as "external",
13
+# so Keycloak demands HTTPS. Two layers are needed:
14
+# - disable-https lets Dev Services reach the master realm's admin API over HTTP to provision.
15
+# - the imported realm sets sslRequired=none so the app's runtime OIDC discovery over HTTP is accepted
16
+# (the auto-created realm would otherwise default to sslRequired=external and 403 with "HTTPS required").
17
+# The realm file also ships the quarkus-app client + alice/bob users.
18
+%dev,test.quarkus.keycloak.devservices.disable-https=true
19
+%dev,test.quarkus.keycloak.devservices.realm-path=quarkus-realm.json
20
+%dev,test.quarkus.oidc.client-id=quarkus-app
21
+%dev,test.quarkus.oidc.credentials.secret=secret
12
22
quarkus.oidc.application-type=web-app
13
23
quarkus.oidc.logout.path=/logout
14
24
quarkus.oidc.logout.post-logout-path=/
ADD
src/main/resources/db/migration/V2__repository_pins.sql
+8 -0
@@ -0,0 +1,8 @@
1
+create table repository_pins
2
+(
3
+ id uuid primary key,
4
+ user_id uuid not null references users (id) on delete cascade,
5
+ repository_id uuid not null references repositories (id) on delete cascade,
6
+ created_at timestamptz not null default now(),
7
+ unique (user_id, repository_id)
8
+);
ADD
src/main/resources/templates/HomeResource/dashboard.html
+77 -0
@@ -0,0 +1,77 @@
1
+{#include layout}
2
+{#title}git-shark{/title}
3
+<p><a class="btn btn-primary" href="/repos/new">New repository</a></p>
4
+
5
+<section class="dashboard-section">
6
+ <h2>Pinned</h2>
7
+ {#if pinned.isEmpty()}
8
+ <p class="muted empty-state">No pinned repositories yet — pin the repositories you work in regularly for quick access.</p>
9
+ {#else}
10
+ <table>
11
+ <tr><th>Repository</th><th>Visibility</th><th></th></tr>
12
+ {#for repo in pinned}
13
+ <tr>
14
+ <td><a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
15
+ <td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
16
+ <td class="actions">
17
+ <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/unpin">
18
+ <input type="hidden" name="redirect" value="/">
19
+ <button class="btn-icon pinned" type="submit" title="Unpin repository" aria-label="Unpin repository">
20
+ <svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
21
+ </button>
22
+ </form>
23
+ </td>
24
+ </tr>
25
+ {/for}
26
+ </table>
27
+ {/if}
28
+</section>
29
+
30
+<section class="dashboard-section">
31
+ <h2>Notifications</h2>
32
+ {#if notifications.isEmpty()}
33
+ <p class="muted empty-state">No notifications — assigned issues and merge requests will appear here once those features land.</p>
34
+ {#else}
35
+ <table>
36
+ <tr><th>Type</th><th>What</th></tr>
37
+ {#for item in notifications}
38
+ <tr>
39
+ <td><span class="badge">{item.category}</span></td>
40
+ <td><a href="{item.targetUrl}">{item.title}</a></td>
41
+ </tr>
42
+ {/for}
43
+ </table>
44
+ {/if}
45
+</section>
46
+
47
+<section class="dashboard-section">
48
+ <h2>All repositories</h2>
49
+ <table>
50
+ <tr><th>Repository</th><th>Visibility</th><th>Description</th><th></th></tr>
51
+ {#for row in repositories}
52
+ <tr>
53
+ <td><a class="mono" href="/repos/{row.repo.owner.username}/{row.repo.name}">{row.repo.owner.username}/{row.repo.name}</a></td>
54
+ <td><span class="badge badge-{row.repo.visibility.name().toLowerCase()}">{row.repo.visibility.name().toLowerCase()}</span></td>
55
+ <td class="muted">{row.repo.description ?: ''}</td>
56
+ <td class="actions">
57
+ {#if row.pinned}
58
+ <form class="inline" method="post" action="/repos/{row.repo.owner.username}/{row.repo.name}/unpin">
59
+ <input type="hidden" name="redirect" value="/">
60
+ <button class="btn-icon pinned" type="submit" title="Unpin repository" aria-label="Unpin repository">
61
+ <svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
62
+ </button>
63
+ </form>
64
+ {#else}
65
+ <form class="inline" method="post" action="/repos/{row.repo.owner.username}/{row.repo.name}/pin">
66
+ <input type="hidden" name="redirect" value="/">
67
+ <button class="btn-icon" type="submit" title="Pin repository" aria-label="Pin repository">
68
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
69
+ </button>
70
+ </form>
71
+ {/if}
72
+ </td>
73
+ </tr>
74
+ {/for}
75
+ </table>
76
+</section>
77
+{/include}
MODIFY
src/main/resources/templates/RepositoryResource/overview.html
+8 -0
@@ -7,6 +7,14 @@
7
7
<p>
8
8
Clone: <code>{httpUrl}</code> · <code>{sshUrl}</code>
9
9
</p>
10
+{#if loggedIn}
11
+<form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/{#if pinned}unpin{#else}pin{/if}">
12
+ <input type="hidden" name="redirect" value="/repos/{repo.owner.username}/{repo.name}">
13
+ <button class="btn-icon{#if pinned} pinned{/if}" type="submit" title="{#if pinned}Unpin{#else}Pin{/if} repository" aria-label="{#if pinned}Unpin{#else}Pin{/if} repository">
14
+ <svg viewBox="0 0 24 24" fill="{#if pinned}currentColor{#else}none{/if}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
15
+ </button>
16
+</form>
17
+{/if}
10
18
{#if empty}
11
19
<h2>Quick start</h2>
12
20
<p>This repository is empty. Push an existing repository:</p>
ADD
src/test/java/de/workaround/git/RepositoryPinServiceTest.java
+108 -0
@@ -0,0 +1,108 @@
1
+package de.workaround.git;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.model.Repository;
8
+import de.workaround.model.RepositoryPin;
9
+import de.workaround.model.User;
10
+import io.quarkus.test.TestTransaction;
11
+import io.quarkus.test.junit.QuarkusTest;
12
+import jakarta.inject.Inject;
13
+import jakarta.persistence.EntityManager;
14
+
15
+import static org.junit.jupiter.api.Assertions.assertEquals;
16
+import static org.junit.jupiter.api.Assertions.assertFalse;
17
+import static org.junit.jupiter.api.Assertions.assertTrue;
18
+
19
+@QuarkusTest
20
+class RepositoryPinServiceTest
21
+{
22
+ @Inject
23
+ RepositoryPinService pinService;
24
+
25
+ @Inject
26
+ RepositoryPin.Repo pins;
27
+
28
+ @Inject
29
+ Repository.Repo repositories;
30
+
31
+ @Inject
32
+ User.Repo users;
33
+
34
+ @Inject
35
+ EntityManager em;
36
+
37
+ @Test
38
+ @TestTransaction
39
+ void pinIsIdempotentAndUnpinRemovesIt()
40
+ {
41
+ User alice = persistUser("pin-alice");
42
+ Repository repo = persistRepo(alice, "p1");
43
+
44
+ pinService.pin(alice, repo);
45
+ pinService.pin(alice, repo);
46
+
47
+ assertEquals(1, pins.findByUser(alice).size(), "pinning twice must not create a duplicate");
48
+ assertEquals(1, pinService.listPinned(alice).size());
49
+ assertTrue(pinService.isPinned(alice, repo));
50
+
51
+ pinService.unpin(alice, repo);
52
+
53
+ assertFalse(pinService.isPinned(alice, repo));
54
+ assertTrue(pinService.listPinned(alice).isEmpty());
55
+ }
56
+
57
+ @Test
58
+ @TestTransaction
59
+ void deletingARepositoryRemovesItsPins()
60
+ {
61
+ User bob = persistUser("pin-bob");
62
+ Repository repo = persistRepo(bob, "p2");
63
+ pinService.pin(bob, repo);
64
+
65
+ // Flush + detach so the still-managed pin doesn't trip Hibernate's own integrity check;
66
+ // deleting the repository must then drop the pin via the DB-level ON DELETE CASCADE.
67
+ em.flush();
68
+ em.clear();
69
+ repositories.deleteById(repo.id);
70
+ em.flush();
71
+
72
+ assertTrue(pinService.listPinned(bob).isEmpty(), "pins must be cascade-deleted with the repository");
73
+ }
74
+
75
+ @Test
76
+ @TestTransaction
77
+ void pinsAreIsolatedPerUser()
78
+ {
79
+ User carol = persistUser("pin-carol");
80
+ User dave = persistUser("pin-dave");
81
+ Repository shared = persistRepo(carol, "shared");
82
+
83
+ pinService.pin(carol, shared);
84
+
85
+ assertEquals(1, pinService.listPinned(carol).size());
86
+ assertTrue(pinService.listPinned(dave).isEmpty(), "dave must not see carol's pins");
87
+ }
88
+
89
+ private Repository persistRepo(User owner, String name)
90
+ {
91
+ Repository repo = new Repository();
92
+ repo.name = name;
93
+ repo.owner = owner;
94
+ repo.visibility = Repository.Visibility.PUBLIC;
95
+ repo.persist();
96
+ return repo;
97
+ }
98
+
99
+ private User persistUser(String name)
100
+ {
101
+ User user = new User();
102
+ user.oidcSub = name + "-" + UUID.randomUUID();
103
+ user.username = name + "-" + UUID.randomUUID().toString().substring(0, 8);
104
+ user.persist();
105
+ return user;
106
+ }
107
+
108
+}
ADD
src/test/java/de/workaround/notify/NotificationServiceTest.java
+55 -0
@@ -0,0 +1,55 @@
1
+package de.workaround.notify;
2
+
3
+import java.util.List;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.model.User;
8
+import io.quarkus.test.junit.QuarkusTest;
9
+import jakarta.inject.Inject;
10
+
11
+import static org.junit.jupiter.api.Assertions.assertEquals;
12
+import static org.junit.jupiter.api.Assertions.assertTrue;
13
+
14
+@QuarkusTest
15
+class NotificationServiceTest
16
+{
17
+ @Inject
18
+ NotificationService service;
19
+
20
+ @Test
21
+ void returnsEmptyWhenNoSourcesAreRegistered()
22
+ {
23
+ // No concrete NotificationSource beans ship with this change.
24
+ assertTrue(service.notificationsFor(new User()).isEmpty());
25
+ }
26
+
27
+ @Test
28
+ void aggregatesAndOrdersDeterministicallyByCategoryThenTitle()
29
+ {
30
+ NotificationSource issues = user -> List.of(
31
+ new NotificationItem("issue", "Zebra", null, "/z"),
32
+ new NotificationItem("issue", "Apple", null, "/a"));
33
+ NotificationSource mrs = user -> List.of(
34
+ new NotificationItem("merge-request", "Middle", null, "/m"));
35
+
36
+ List<NotificationItem> items = service.aggregate(new User(), List.of(mrs, issues));
37
+
38
+ assertEquals(List.of("Apple", "Zebra", "Middle"), items.stream().map(NotificationItem::title).toList());
39
+ }
40
+
41
+ @Test
42
+ void oneFailingSourceDoesNotBreakAggregation()
43
+ {
44
+ NotificationSource ok = user -> List.of(new NotificationItem("issue", "Survivor", null, "/s"));
45
+ NotificationSource broken = user -> {
46
+ throw new IllegalStateException("boom");
47
+ };
48
+
49
+ List<NotificationItem> items = service.aggregate(new User(), List.of(broken, ok));
50
+
51
+ assertEquals(1, items.size());
52
+ assertEquals("Survivor", items.get(0).title());
53
+ }
54
+
55
+}
ADD
src/test/java/de/workaround/web/DashboardTest.java
+132 -0
@@ -0,0 +1,132 @@
1
+package de.workaround.web;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.Repository;
9
+import de.workaround.model.User;
10
+import io.quarkus.test.junit.QuarkusTest;
11
+import io.quarkus.test.security.TestSecurity;
12
+import io.restassured.http.ContentType;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+
16
+import static io.restassured.RestAssured.given;
17
+import static org.hamcrest.CoreMatchers.containsString;
18
+import static org.hamcrest.CoreMatchers.not;
19
+
20
+@QuarkusTest
21
+class DashboardTest
22
+{
23
+ @Inject
24
+ GitRepositoryService service;
25
+
26
+ @Inject
27
+ User.Repo userRepo;
28
+
29
+ @Test
30
+ @TestSecurity(user = "dash-alice")
31
+ void authenticatedHomeRendersThreeSections()
32
+ {
33
+ User alice = persistUser("dash-alice");
34
+ service.create(alice, "dash-repo-" + unique(), Repository.Visibility.PRIVATE, null);
35
+
36
+ given()
37
+ .when().get("/")
38
+ .then()
39
+ .statusCode(200)
40
+ .body(containsString("Pinned"))
41
+ .body(containsString("Notifications"))
42
+ .body(containsString("All repositories"))
43
+ // notifications empty state references the not-yet-built features
44
+ .body(containsString("assigned issues and merge requests"));
45
+ }
46
+
47
+ @Test
48
+ @TestSecurity(user = "dash-pin")
49
+ void pinningARepositoryReflectsInThePinnedSection()
50
+ {
51
+ User user = persistUser("dash-pin");
52
+ service.create(user, "pinme", Repository.Visibility.PUBLIC, null);
53
+
54
+ given().when().get("/").then().statusCode(200)
55
+ .body(containsString("No pinned repositories yet"));
56
+
57
+ given().redirects().follow(false)
58
+ .contentType(ContentType.URLENC).formParam("redirect", "/")
59
+ .when().post("/repos/dash-pin/pinme/pin")
60
+ .then().statusCode(303);
61
+
62
+ given().when().get("/").then().statusCode(200)
63
+ .body(not(containsString("No pinned repositories yet")))
64
+ .body(containsString("/repos/dash-pin/pinme/unpin"))
65
+ .body(containsString("Unpin repository"));
66
+
67
+ given().redirects().follow(false)
68
+ .contentType(ContentType.URLENC).formParam("redirect", "/")
69
+ .when().post("/repos/dash-pin/pinme/unpin")
70
+ .then().statusCode(303);
71
+
72
+ given().when().get("/").then().statusCode(200)
73
+ .body(containsString("No pinned repositories yet"));
74
+ }
75
+
76
+ @Test
77
+ @TestSecurity(user = "dash-stranger")
78
+ void pinningANonVisibleRepositoryIsRejected()
79
+ {
80
+ persistUser("dash-stranger");
81
+ User owner = persistUser("dash-owner-" + unique());
82
+ service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
83
+
84
+ given().redirects().follow(false)
85
+ .contentType(ContentType.URLENC).formParam("redirect", "/")
86
+ .when().post("/repos/" + owner.username + "/secret/pin")
87
+ .then().statusCode(404);
88
+ }
89
+
90
+ @Test
91
+ void anonymousCannotPin()
92
+ {
93
+ User owner = persistUser("dash-anon-" + unique());
94
+ service.create(owner, "anon-pub", Repository.Visibility.PUBLIC, null);
95
+
96
+ given().redirects().follow(false)
97
+ .contentType(ContentType.URLENC).formParam("redirect", "/")
98
+ .when().post("/repos/" + owner.username + "/anon-pub/pin")
99
+ .then().statusCode(403);
100
+ }
101
+
102
+ @Test
103
+ void anonymousHomeStillServesLandingPage()
104
+ {
105
+ given()
106
+ .when().get("/")
107
+ .then()
108
+ .statusCode(200)
109
+ .body(containsString("Use AI as a tool, not as a feature"));
110
+ }
111
+
112
+ private static String unique()
113
+ {
114
+ return UUID.randomUUID().toString().substring(0, 8);
115
+ }
116
+
117
+ @Transactional
118
+ User persistUser(String name)
119
+ {
120
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
121
+ if (existing != null)
122
+ {
123
+ return existing;
124
+ }
125
+ User user = new User();
126
+ user.oidcSub = name;
127
+ user.username = name;
128
+ user.persist();
129
+ return user;
130
+ }
131
+
132
+}