gitshark

Clone repository

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

← Commits

✨ Add pin feature

f07309b91525e89bada2b91246d8855b09230cb0 · Phillip Souza Furtner · 2026-06-24T11:57:41Z

Changes

22 files changed, +1107 -3

ADD openspec/changes/add-dashboard-pinned-notifications/.openspec.yaml +2 -0
diff --git a/openspec/changes/add-dashboard-pinned-notifications/.openspec.yaml b/openspec/changes/add-dashboard-pinned-notifications/.openspec.yaml
new file mode 100644
index 0000000..6c351ac
--- /dev/null
+++ b/openspec/changes/add-dashboard-pinned-notifications/.openspec.yaml
@@ -0,0 +1,2 @@
1 +schema: spec-driven
2 +created: 2026-06-21
ADD openspec/changes/add-dashboard-pinned-notifications/design.md +103 -0
diff --git a/openspec/changes/add-dashboard-pinned-notifications/design.md b/openspec/changes/add-dashboard-pinned-notifications/design.md
new file mode 100644
index 0000000..fa56dd1
--- /dev/null
+++ b/openspec/changes/add-dashboard-pinned-notifications/design.md
@@ -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
diff --git a/openspec/changes/add-dashboard-pinned-notifications/proposal.md b/openspec/changes/add-dashboard-pinned-notifications/proposal.md
new file mode 100644
index 0000000..8e1c7ce
--- /dev/null
+++ b/openspec/changes/add-dashboard-pinned-notifications/proposal.md
@@ -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
diff --git a/openspec/changes/add-dashboard-pinned-notifications/specs/dashboard-notifications/spec.md b/openspec/changes/add-dashboard-pinned-notifications/specs/dashboard-notifications/spec.md
new file mode 100644
index 0000000..7a3f2c2
--- /dev/null
+++ b/openspec/changes/add-dashboard-pinned-notifications/specs/dashboard-notifications/spec.md
@@ -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
diff --git a/openspec/changes/add-dashboard-pinned-notifications/specs/repository-pinning/spec.md b/openspec/changes/add-dashboard-pinned-notifications/specs/repository-pinning/spec.md
new file mode 100644
index 0000000..ae3941b
--- /dev/null
+++ b/openspec/changes/add-dashboard-pinned-notifications/specs/repository-pinning/spec.md
@@ -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
diff --git a/openspec/changes/add-dashboard-pinned-notifications/tasks.md b/openspec/changes/add-dashboard-pinned-notifications/tasks.md
new file mode 100644
index 0000000..a2d88f2
--- /dev/null
+++ b/openspec/changes/add-dashboard-pinned-notifications/tasks.md
@@ -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
diff --git a/src/main/java/de/workaround/git/RepositoryPinService.java b/src/main/java/de/workaround/git/RepositoryPinService.java
new file mode 100644
index 0000000..6dc780d
--- /dev/null
+++ b/src/main/java/de/workaround/git/RepositoryPinService.java
@@ -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
diff --git a/src/main/java/de/workaround/model/RepositoryPin.java b/src/main/java/de/workaround/model/RepositoryPin.java
new file mode 100644
index 0000000..b85aaa7
--- /dev/null
+++ b/src/main/java/de/workaround/model/RepositoryPin.java
@@ -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
diff --git a/src/main/java/de/workaround/notify/NotificationItem.java b/src/main/java/de/workaround/notify/NotificationItem.java
new file mode 100644
index 0000000..f970eec
--- /dev/null
+++ b/src/main/java/de/workaround/notify/NotificationItem.java
@@ -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
diff --git a/src/main/java/de/workaround/notify/NotificationService.java b/src/main/java/de/workaround/notify/NotificationService.java
new file mode 100644
index 0000000..9e74359
--- /dev/null
+++ b/src/main/java/de/workaround/notify/NotificationService.java
@@ -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
diff --git a/src/main/java/de/workaround/notify/NotificationSource.java b/src/main/java/de/workaround/notify/NotificationSource.java
new file mode 100644
index 0000000..5539735
--- /dev/null
+++ b/src/main/java/de/workaround/notify/NotificationSource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/ForbiddenOperationExceptionMapper.java b/src/main/java/de/workaround/web/ForbiddenOperationExceptionMapper.java
new file mode 100644
index 0000000..43bb129
--- /dev/null
+++ b/src/main/java/de/workaround/web/ForbiddenOperationExceptionMapper.java
@@ -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
diff --git a/src/main/java/de/workaround/web/HomeResource.java b/src/main/java/de/workaround/web/HomeResource.java
index 098acf3..63d97ca 100644
--- a/src/main/java/de/workaround/web/HomeResource.java
+++ b/src/main/java/de/workaround/web/HomeResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index c6791a0..c7492b2 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 891b6c5..27622b1 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -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
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index f01696e..7ad8fe4 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/db/migration/V2__repository_pins.sql b/src/main/resources/db/migration/V2__repository_pins.sql
new file mode 100644
index 0000000..ffd2c62
--- /dev/null
+++ b/src/main/resources/db/migration/V2__repository_pins.sql
@@ -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
diff --git a/src/main/resources/templates/HomeResource/dashboard.html b/src/main/resources/templates/HomeResource/dashboard.html
new file mode 100644
index 0000000..1fdedad
--- /dev/null
+++ b/src/main/resources/templates/HomeResource/dashboard.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/overview.html b/src/main/resources/templates/RepositoryResource/overview.html
index 029ae9f..adf36b7 100644
--- a/src/main/resources/templates/RepositoryResource/overview.html
+++ b/src/main/resources/templates/RepositoryResource/overview.html
@@ -7,6 +7,14 @@
7 7 <p>
8 8 Clone: <code>{httpUrl}</code> &middot; <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
diff --git a/src/test/java/de/workaround/git/RepositoryPinServiceTest.java b/src/test/java/de/workaround/git/RepositoryPinServiceTest.java
new file mode 100644
index 0000000..bd07d74
--- /dev/null
+++ b/src/test/java/de/workaround/git/RepositoryPinServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/notify/NotificationServiceTest.java b/src/test/java/de/workaround/notify/NotificationServiceTest.java
new file mode 100644
index 0000000..310ef23
--- /dev/null
+++ b/src/test/java/de/workaround/notify/NotificationServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/DashboardTest.java b/src/test/java/de/workaround/web/DashboardTest.java
new file mode 100644
index 0000000..9dec7ad
--- /dev/null
+++ b/src/test/java/de/workaround/web/DashboardTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog