✨ (collaborators): Add repository collaborators with read/write access
Changes
23 files changed, +836 -25
MODIFY
README.md
+17 -9
@@ -14,18 +14,25 @@
14
14
- push and private read authenticate with **personal access tokens** (HTTP Basic password)
15
15
- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16
16
- public-key authentication only; keys managed per user in the UI
17
-- Web UI: an auth-aware header nav (a "Log in" button for visitors; Profile, SSH keys, Access tokens, and Logout links only for signed-in users), landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
18
-- Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New issue" page
17
+- Web UI: an auth-aware header nav (a "Log in" button for visitors; Profile, SSH keys, Access tokens, and Logout links only for signed-in users), landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests, plus Collaborators for the owner) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
18
+- Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page
19
19
- Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
20
20
- Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
21
-- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New merge request" page where the owner picks source and target from the repo's branches
21
+- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches
22
22
- Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and left sidebar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
23
23
- The merge request detail page renders the live diff of the source branch relative to the merge base with the target (three-dot diff), file by file with per-line add/delete coloring and a changed-files / +additions / −deletions summary — always computed live from git, never duplicated into the database
24
-- The owner can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
25
-- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author or by the repository owner. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
24
+- The owner or a collaborator can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
25
+- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author, the repository owner, or a collaborator. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
26
26
- OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
27
27
- Profile pictures: users can upload a PNG/JPEG/GIF/WebP avatar (≤ 2 MB, content-type and magic bytes both validated) at `/settings/profile`, stored on the filesystem keyed by user UUID and served publicly at `GET /users/{username}/avatar`; shown wherever a local user is rendered (header nav, repo lists, repo sidebar, issue/MR/comment authors) via a reusable Qute avatar tag, removable, and falling back to an initials badge when absent. Git commit authors and remote federation actors are not local users and keep their existing pseudo-avatars
28
-- Single access policy on all paths: owner read/write, public world-readable, private owner-only
28
+- **Collaborators** — the repository owner can grant other local users read+write access
29
+ (one flat role) on a per-repository settings page (`…/settings/collaborators`, linked in the
30
+ repo sidebar for the owner). Collaborators can read and push — UI, HTTP, and SSH alike — even
31
+ on private repositories, and manage issues and merge requests; deleting the repository,
32
+ managing mirrors, and managing collaborators stay owner-only. Guides:
33
+ [for users](docs/users/collaborators.md), [for admins](docs/admins/collaborators.md)
34
+- Single access policy on all paths: owner read/write, collaborators read/write, public
35
+ world-readable, private repositories visible to the owner and collaborators only
29
36
- **JSON REST API** under `/api/v1`, authenticated with the same personal access tokens as
30
37
git-over-HTTP (`Authorization: Bearer <token>`), auto-documented via OpenAPI/Swagger UI (see below)
31
38
- **MCP server** at `/mcp` (Streamable HTTP), exposing the same feature set as the REST API as
@@ -84,8 +91,9 @@
84
91
85
92
- Authenticated with the **same personal access tokens** used for git-over-HTTP, but sent as
86
93
`Authorization: Bearer <token>` (not HTTP Basic)
87
-- Anonymous requests are allowed for public reads only; mutations require a token and repository
88
- ownership (posting a comment only requires read access)
94
+- Anonymous requests are allowed for public reads only; mutations require a token and write
95
+ access (owner or collaborator; posting a comment only requires read access, deleting a
96
+ repository stays owner-only)
89
97
- Same visibility rules as the UI: private repositories are hidden as `404`, not `403`
90
98
91
99
| Method | Path | Description |
@@ -102,7 +110,7 @@
102
110
| POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/merge` | Merge |
103
111
| POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/close` | Close |
104
112
| GET, POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments` | List / add line-level review comments (any reader may comment) |
105
-| DELETE | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments/{commentId}` | Delete a comment (author or repo owner) |
113
+| DELETE | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments/{commentId}` | Delete a comment (author, repo owner, or collaborator) |
106
114
107
115
## MCP server
108
116
MODIFY
docs/README.md
+4 -0
@@ -15,6 +15,8 @@
15
15
- **[Push mirrors](users/mirrors.md)** — replicate a repository to an external
16
16
remote on every push: HTTPS vs SSH setup, deploy keys, status and
17
17
troubleshooting.
18
+- **[Collaborators](users/collaborators.md)** — grant other users read and
19
+ write access to your repository, what collaborators can and cannot do.
18
20
19
21
### For admins
20
22
@@ -25,6 +27,8 @@
25
27
configuration, allowlist, reverse-proxy requirements, delivery queue, monitoring.
26
28
- **[Push mirrors](admins/mirrors.md)** — the secret key, outbound network and
27
29
SSRF behavior, the sync queue, and the tables involved.
30
+- **[Collaborators](admins/collaborators.md)** — access semantics, endpoints,
31
+ and the `repository_collaborators` table (no configuration needed).
28
32
29
33
### For maintainers
30
34
ADD
docs/admins/collaborators.md
+37 -0
@@ -0,0 +1,37 @@
1
+# Operating collaborators
2
+
3
+Collaborators let a repository owner grant other **local** users read+write access to a
4
+repository (one flat role). This page covers what an operator must know: there is no
5
+configuration, one new table, and no new proxy rules.
6
+
7
+## Configuration
8
+
9
+None. The feature is always on and needs no environment variables.
10
+
11
+## Endpoints
12
+
13
+Collaborator management happens on existing web UI paths — no new reverse-proxy rules:
14
+
15
+- `GET /repos/{owner}/{name}/settings/collaborators` — the settings page (owner-only;
16
+ non-owners get `403`, users who cannot see the repository get `404`)
17
+- `POST /repos/{owner}/{name}/settings/collaborators` — add by username
18
+- `POST /repos/{owner}/{name}/settings/collaborators/{username}/remove` — remove
19
+
20
+## Access semantics
21
+
22
+- A collaborator gets **read and write** on the repository over every path — web UI, git
23
+ over HTTP, git over SSH, REST API, and MCP — including read access to **private**
24
+ repositories.
25
+- Collaborators can manage issues and merge requests like the owner.
26
+- Deleting the repository, managing push mirrors, and managing collaborators stay
27
+ **owner-only**.
28
+- Removing a collaborator takes effect on the next request; there is no session to
29
+ invalidate.
30
+
31
+## Tables
32
+
33
+| Table | Contents |
34
+|---|---|
35
+| `repository_collaborators` | One row per grant: `repository_id`, `user_id`, `created_at`; unique per (repository, user), rows cascade-deleted with the repository or the user |
36
+
37
+Added by migration `V12__repository_collaborators.sql`.
ADD
docs/users/collaborators.md
+40 -0
@@ -0,0 +1,40 @@
1
+# Collaborators
2
+
3
+A collaborator is another user on your git-shark instance whom you grant **read and
4
+write** access to one of your repositories. There is one flat collaborator role — no
5
+permission levels, no teams. Adding a collaborator takes effect immediately; there is
6
+no invitation to accept.
7
+
8
+Only the **repository owner** can see and manage a repository's collaborators.
9
+
10
+## What a collaborator can do
11
+
12
+- Read the repository — browse it in the web UI and clone/fetch it over HTTP and SSH,
13
+ including when the repository is **private**.
14
+- Push over HTTP and SSH.
15
+- Open and manage issues and merge requests, and moderate merge-request review comments,
16
+ just like the owner.
17
+
18
+A collaborator can **not**:
19
+
20
+- Delete the repository.
21
+- Manage push mirrors.
22
+- Add or remove collaborators — granting access stays with the owner.
23
+
24
+## Adding a collaborator
25
+
26
+1. Open your repository. As the owner you'll find a **Collaborators** entry in the left
27
+ sidebar (it links to `…/settings/collaborators`).
28
+2. Enter the user's exact **username** (the handle used in their profile URLs — there is
29
+ no search or autocomplete) and click **Add**.
30
+
31
+The page tells you when the username doesn't exist, the user is already a collaborator,
32
+or you tried to add yourself.
33
+
34
+Only local users can be added — collaborators from other federated instances are not
35
+supported.
36
+
37
+## Removing a collaborator
38
+
39
+On the same page, click **Remove** next to the collaborator. Their read and write access
40
+ends immediately; on a private repository they lose all access.
MODIFY
src/main/java/de/workaround/git/AccessPolicy.java
+26 -4
@@ -3,22 +3,33 @@
3
3
import de.workaround.model.Repository;
4
4
import de.workaround.model.User;
5
5
import jakarta.enterprise.context.ApplicationScoped;
6
+import jakarta.inject.Inject;
6
7
7
8
/**
8
- * Single authorization rule set shared by HTTP, SSH and UI: the owner has full access,
9
- * public repositories are world-readable, private repositories are owner-only.
9
+ * Single authorization rule set shared by HTTP, SSH and UI: the owner has full access, collaborators
10
+ * read and write repositories they were added to, public repositories are world-readable, private
11
+ * repositories are visible to the owner and collaborators only.
10
12
*/
11
13
@ApplicationScoped
12
14
public class AccessPolicy
13
15
{
16
+ private final CollaboratorLookup collaborators;
17
+
18
+ @Inject
19
+ public AccessPolicy(CollaboratorLookup collaborators)
20
+ {
21
+ this.collaborators = collaborators;
22
+ }
23
+
14
24
public boolean canRead(User user, Repository repository)
15
25
{
16
- return repository.visibility == Repository.Visibility.PUBLIC || isOwner(user, repository);
26
+ return repository.visibility == Repository.Visibility.PUBLIC || isOwner(user, repository)
27
+ || isCollaborator(user, repository);
17
28
}
18
29
19
30
public boolean canWrite(User user, Repository repository)
20
31
{
21
- return isOwner(user, repository);
32
+ return isOwner(user, repository) || isCollaborator(user, repository);
22
33
}
23
34
24
35
private static boolean isOwner(User user, Repository repository)
@@ -26,4 +37,15 @@
26
37
return user != null && user.id != null && user.id.equals(repository.owner.id);
27
38
}
28
39
40
+ private boolean isCollaborator(User user, Repository repository)
41
+ {
42
+ return user != null && user.id != null && collaborators.isCollaborator(user, repository);
43
+ }
44
+
45
+ /** How the policy asks whether a user holds a collaborator grant; implemented by {@link CollaboratorService}. */
46
+ public interface CollaboratorLookup
47
+ {
48
+ boolean isCollaborator(User user, Repository repository);
49
+ }
50
+
29
51
}
ADD
src/main/java/de/workaround/git/CollaboratorService.java
+75 -0
@@ -0,0 +1,75 @@
1
+package de.workaround.git;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.model.Repository;
6
+import de.workaround.model.RepositoryCollaborator;
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 the flat collaborator role on a repository: the owner grants and revokes read+write
14
+ * access for other local users by username. Also serves as the {@link AccessPolicy}'s collaborator
15
+ * lookup, so HTTP, SSH and UI authorization all see the same grants.
16
+ */
17
+@ApplicationScoped
18
+public class CollaboratorService implements AccessPolicy.CollaboratorLookup
19
+{
20
+ @Inject
21
+ RepositoryCollaborator.Repo collaborators;
22
+
23
+ @Inject
24
+ User.Repo users;
25
+
26
+ @Transactional
27
+ public RepositoryCollaborator add(User actor, Repository repository, String username)
28
+ {
29
+ requireOwner(actor, repository);
30
+ User user = users.findByUsername(username == null ? "" : username.strip())
31
+ .orElseThrow(() -> new InvalidCollaboratorException("No user with that username exists."));
32
+ if (user.id.equals(repository.owner.id))
33
+ {
34
+ throw new InvalidCollaboratorException("The owner cannot be added as a collaborator.");
35
+ }
36
+ if (collaborators.findByRepositoryAndUser(repository, user).isPresent())
37
+ {
38
+ throw new InvalidCollaboratorException("That user is already a collaborator.");
39
+ }
40
+ RepositoryCollaborator collaborator = new RepositoryCollaborator();
41
+ collaborator.repository = repository;
42
+ collaborator.user = user;
43
+ collaborator.persist();
44
+ return collaborator;
45
+ }
46
+
47
+ @Transactional
48
+ public void remove(User actor, Repository repository, String username)
49
+ {
50
+ requireOwner(actor, repository);
51
+ users.findByUsername(username == null ? "" : username.strip())
52
+ .flatMap(user -> collaborators.findByRepositoryAndUser(repository, user))
53
+ .ifPresent(collaborator -> collaborators.deleteById(collaborator.id));
54
+ }
55
+
56
+ public List<RepositoryCollaborator> list(Repository repository)
57
+ {
58
+ return collaborators.findByRepository(repository);
59
+ }
60
+
61
+ @Override
62
+ public boolean isCollaborator(User user, Repository repository)
63
+ {
64
+ return collaborators.findByRepositoryAndUser(repository, user).isPresent();
65
+ }
66
+
67
+ private static void requireOwner(User actor, Repository repository)
68
+ {
69
+ if (actor == null || actor.id == null || !actor.id.equals(repository.owner.id))
70
+ {
71
+ throw new ForbiddenOperationException("Only the repository owner can manage collaborators");
72
+ }
73
+ }
74
+
75
+}
ADD
src/main/java/de/workaround/git/InvalidCollaboratorException.java
+14 -0
@@ -0,0 +1,14 @@
1
+package de.workaround.git;
2
+
3
+/**
4
+ * Rejected collaborator mutation: the username does not resolve to a local user, the user already
5
+ * is a collaborator, or the user is the repository owner. Carries the user-facing message shown on
6
+ * the collaborators settings page.
7
+ */
8
+public class InvalidCollaboratorException extends RuntimeException
9
+{
10
+ public InvalidCollaboratorException(String message)
11
+ {
12
+ super(message);
13
+ }
14
+}
MODIFY
src/main/java/de/workaround/git/IssueService.java
+1 -1
@@ -102,7 +102,7 @@
102
102
{
103
103
if (!accessPolicy.canWrite(actor, repository))
104
104
{
105
- throw new ForbiddenOperationException("Only the repository owner can manage issues");
105
+ throw new ForbiddenOperationException("Only the repository owner or a collaborator can manage issues");
106
106
}
107
107
}
108
108
MODIFY
src/main/java/de/workaround/git/MergeRequestCommentService.java
+1 -1
@@ -75,7 +75,7 @@
75
75
boolean isOwner = accessPolicy.canWrite(actor, managed.mergeRequest.repository);
76
76
if (!isAuthor && !isOwner)
77
77
{
78
- throw new ForbiddenOperationException("Only the comment author or the repository owner can delete it");
78
+ throw new ForbiddenOperationException("Only the comment author, the repository owner or a collaborator can delete it");
79
79
}
80
80
comments.deleteById(managed.id);
81
81
}
MODIFY
src/main/java/de/workaround/git/MergeRequestService.java
+1 -1
@@ -158,7 +158,7 @@
158
158
{
159
159
if (!accessPolicy.canWrite(actor, repository))
160
160
{
161
- throw new ForbiddenOperationException("Only the repository owner can manage merge requests");
161
+ throw new ForbiddenOperationException("Only the repository owner or a collaborator can manage merge requests");
162
162
}
163
163
}
164
164
ADD
src/main/java/de/workaround/model/RepositoryCollaborator.java
+51 -0
@@ -0,0 +1,51 @@
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
+ * Grants a local user read and write access to a repository they do not own — one flat
23
+ * collaborator role, managed by the owner. Uniqueness is enforced per (repository, user).
24
+ */
25
+@Entity
26
+@Table(name = "repository_collaborators",
27
+ uniqueConstraints = @UniqueConstraint(columnNames = { "repository_id", "user_id" }))
28
+public class RepositoryCollaborator implements PanacheEntity.Managed
29
+{
30
+ @Id
31
+ @GeneratedValue(strategy = GenerationType.UUID)
32
+ public UUID id;
33
+
34
+ @ManyToOne(optional = false)
35
+ public Repository repository;
36
+
37
+ @ManyToOne(optional = false)
38
+ public User user;
39
+
40
+ public Instant createdAt = Instant.now();
41
+
42
+ public interface Repo extends PanacheRepository.Managed<RepositoryCollaborator, UUID>
43
+ {
44
+ @Find
45
+ Optional<RepositoryCollaborator> findByRepositoryAndUser(Repository repository, User user);
46
+
47
+ @HQL("where repository = :repository order by user.username")
48
+ List<RepositoryCollaborator> findByRepository(Repository repository);
49
+ }
50
+
51
+}
ADD
src/main/java/de/workaround/web/CollaboratorResource.java
+123 -0
@@ -0,0 +1,123 @@
1
+package de.workaround.web;
2
+
3
+import java.net.URI;
4
+import java.util.List;
5
+
6
+import de.workaround.account.CurrentUser;
7
+import de.workaround.git.AccessPolicy;
8
+import de.workaround.git.ForbiddenOperationException;
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.git.InvalidCollaboratorException;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.RepositoryCollaborator;
13
+import de.workaround.model.User;
14
+import io.quarkus.qute.CheckedTemplate;
15
+import io.quarkus.qute.TemplateInstance;
16
+import jakarta.inject.Inject;
17
+import jakarta.ws.rs.Consumes;
18
+import jakarta.ws.rs.FormParam;
19
+import jakarta.ws.rs.GET;
20
+import jakarta.ws.rs.NotFoundException;
21
+import jakarta.ws.rs.POST;
22
+import jakarta.ws.rs.Path;
23
+import jakarta.ws.rs.PathParam;
24
+import jakarta.ws.rs.Produces;
25
+import jakarta.ws.rs.core.Context;
26
+import jakarta.ws.rs.core.MediaType;
27
+import jakarta.ws.rs.core.Response;
28
+import jakarta.ws.rs.core.UriInfo;
29
+
30
+/**
31
+ * Owner-only collaborator management under the repository settings. Non-owners who can see the
32
+ * repository get 403; users who cannot see it get 404 so private repositories stay hidden.
33
+ * Managing collaborators deliberately stays owner-only — a collaborator's write access covers git
34
+ * and repo content, not granting access to others.
35
+ */
36
+@Path("/repos/{owner}/{name}/settings/collaborators")
37
+@Produces(MediaType.TEXT_HTML)
38
+public class CollaboratorResource
39
+{
40
+ @CheckedTemplate
41
+ static class Templates
42
+ {
43
+ static native TemplateInstance collaborators(Repository repo, RepoNav nav,
44
+ List<RepositoryCollaborator> collaborators, String error);
45
+ }
46
+
47
+ @Inject
48
+ CurrentUser currentUser;
49
+
50
+ @Inject
51
+ GitRepositoryService service;
52
+
53
+ @Inject
54
+ AccessPolicy accessPolicy;
55
+
56
+ @Inject
57
+ de.workaround.git.CollaboratorService collaboratorService;
58
+
59
+ @Inject
60
+ RepoNavService repoNav;
61
+
62
+ @Context
63
+ UriInfo uriInfo;
64
+
65
+ @GET
66
+ public TemplateInstance page(@PathParam("owner") String owner, @PathParam("name") String name)
67
+ {
68
+ Repository repo = requireOwned(owner, name);
69
+ return Templates.collaborators(repo, repoNav.build(repo, uriInfo), collaboratorService.list(repo), null);
70
+ }
71
+
72
+ @POST
73
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
74
+ public Response add(@PathParam("owner") String owner, @PathParam("name") String name,
75
+ @FormParam("username") String username)
76
+ {
77
+ Repository repo = requireOwned(owner, name);
78
+ try
79
+ {
80
+ collaboratorService.add(currentUser.require(), repo, username);
81
+ }
82
+ catch (InvalidCollaboratorException e)
83
+ {
84
+ return Response.status(Response.Status.BAD_REQUEST)
85
+ .entity(Templates.collaborators(repo, repoNav.build(repo, uriInfo), collaboratorService.list(repo),
86
+ e.getMessage()))
87
+ .build();
88
+ }
89
+ return backToSettings(owner, name);
90
+ }
91
+
92
+ @POST
93
+ @Path("{username}/remove")
94
+ public Response remove(@PathParam("owner") String owner, @PathParam("name") String name,
95
+ @PathParam("username") String username)
96
+ {
97
+ Repository repo = requireOwned(owner, name);
98
+ collaboratorService.remove(currentUser.require(), repo, username);
99
+ return backToSettings(owner, name);
100
+ }
101
+
102
+ private Repository requireOwned(String owner, String name)
103
+ {
104
+ Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
105
+ User user = currentUser.get();
106
+ if (!accessPolicy.canRead(user, repo))
107
+ {
108
+ // hide existence of private repositories
109
+ throw new NotFoundException();
110
+ }
111
+ if (user == null || !user.id.equals(repo.owner.id))
112
+ {
113
+ throw new ForbiddenOperationException("Only the repository owner can manage collaborators");
114
+ }
115
+ return repo;
116
+ }
117
+
118
+ private static Response backToSettings(String owner, String name)
119
+ {
120
+ return Response.seeOther(URI.create("/repos/" + owner + "/" + name + "/settings/collaborators")).build();
121
+ }
122
+
123
+}
MODIFY
src/main/java/de/workaround/web/IssueResource.java
+2 -2
@@ -79,10 +79,10 @@
79
79
public TemplateInstance newForm(@PathParam("owner") String owner, @PathParam("name") String name)
80
80
{
81
81
Repository repo = requireReadable(owner, name);
82
- // only the owner may open an issue, so only the owner sees the form
82
+ // only users with write access (owner or collaborator) may open an issue
83
83
if (!accessPolicy.canWrite(currentUser.get(), repo))
84
84
{
85
- throw new de.workaround.git.ForbiddenOperationException("Only the repository owner can open issues");
85
+ throw new de.workaround.git.ForbiddenOperationException("Only the repository owner or a collaborator can open issues");
86
86
}
87
87
return Templates.newIssue(repo, repoNav.build(repo, uriInfo));
88
88
}
MODIFY
src/main/java/de/workaround/web/MergeRequestResource.java
+1 -1
@@ -109,7 +109,7 @@
109
109
Repository repo = requireReadable(owner, name);
110
110
if (!accessPolicy.canWrite(currentUser.get(), repo))
111
111
{
112
- throw new ForbiddenOperationException("Only the repository owner can open merge requests");
112
+ throw new ForbiddenOperationException("Only the repository owner or a collaborator can open merge requests");
113
113
}
114
114
Path path = service.repositoryPath(repo);
115
115
List<String> branches = browse.branches(path).stream().map(GitBrowseService.BranchInfo::name).toList();
MODIFY
src/main/java/de/workaround/web/RepoNav.java
+3 -3
@@ -7,8 +7,8 @@
7
7
* request path (so the pin toggle returns the user to where they were), pin state, and the per-section counts shown
8
8
* next to each nav item. Assembled once per request by {@link RepoNavService}.
9
9
*/
10
-public record RepoNav(Repository repo, boolean loggedIn, boolean pinned, boolean empty, String defaultBranch,
11
- int commitCount, int branchCount, int tagCount, long openIssueCount, long openMrCount, String httpUrl,
12
- String sshUrl, String currentPath)
10
+public record RepoNav(Repository repo, boolean loggedIn, boolean isOwner, boolean pinned, boolean empty,
11
+ String defaultBranch, int commitCount, int branchCount, int tagCount, long openIssueCount, long openMrCount,
12
+ String httpUrl, String sshUrl, String currentPath)
13
13
{
14
14
}
MODIFY
src/main/java/de/workaround/web/RepoNavService.java
+2 -1
@@ -51,6 +51,7 @@
51
51
String defaultBranch = empty ? null : browse.defaultBranch(path);
52
52
User user = currentUser.get();
53
53
boolean loggedIn = user != null;
54
+ boolean isOwner = loggedIn && user.id.equals(repo.owner.id);
54
55
boolean pinned = loggedIn && pinService.isPinned(user, repo);
55
56
int commitCount = empty ? 0 : browse.commitCount(path, defaultBranch);
56
57
int branchCount = browse.branches(path).size();
@@ -61,7 +62,7 @@
61
62
.toString();
62
63
String sshUrl = "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort + "/" + repo.owner.username
63
64
+ "/" + repo.name + ".git";
64
- return new RepoNav(repo, loggedIn, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
65
+ return new RepoNav(repo, loggedIn, isOwner, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
65
66
openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath());
66
67
}
67
68
}
ADD
src/main/resources/db/migration/V12__repository_collaborators.sql
+8 -0
@@ -0,0 +1,8 @@
1
+create table repository_collaborators
2
+(
3
+ id uuid primary key,
4
+ repository_id uuid not null references repositories (id) on delete cascade,
5
+ user_id uuid not null references users (id) on delete cascade,
6
+ created_at timestamptz not null default now(),
7
+ unique (repository_id, user_id)
8
+);
ADD
src/main/resources/templates/CollaboratorResource/collaborators.html
+35 -0
@@ -0,0 +1,35 @@
1
+{#include layout}
2
+{#title}Collaborators – {repo.name}{/title}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='collaborators' /}
5
+ <section class="repo-main">
6
+ <h2>Collaborators</h2>
7
+ <p>Collaborators can read and push to this repository, including when it is private. Only you can manage them.</p>
8
+ {#if error}
9
+ <p class="error">{error}</p>
10
+ {/if}
11
+ {#if collaborators.empty}
12
+ <p>No collaborators yet.</p>
13
+ {#else}
14
+ <table>
15
+ <tr><th>User</th><th class="actions"></th></tr>
16
+ {#for collaborator in collaborators}
17
+ <tr>
18
+ <td>{#avatar user=collaborator.user /} {collaborator.user.username}</td>
19
+ <td class="actions">
20
+ <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/settings/collaborators/{collaborator.user.username}/remove">
21
+ <button class="btn btn-danger btn-sm">Remove</button>
22
+ </form>
23
+ </td>
24
+ </tr>
25
+ {/for}
26
+ </table>
27
+ {/if}
28
+ <h3>Add collaborator</h3>
29
+ <form method="post" action="/repos/{repo.owner.username}/{repo.name}/settings/collaborators">
30
+ <p><label>Username <input name="username" required></label></p>
31
+ <button class="btn btn-primary">Add</button>
32
+ </form>
33
+ </section>
34
+</div>
35
+{/include}
MODIFY
src/main/resources/templates/RepositoryResource/sidebar.html
+3 -0
@@ -28,6 +28,9 @@
28
28
<a class="{#if active == 'tags'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/tags"><span class="g">⬡</span> Tags <span class="ct">{nav.tagCount}</span></a>
29
29
<a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
30
30
<a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
31
+ {#if nav.isOwner}
32
+ <a class="{#if active == 'collaborators'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/settings/collaborators"><span class="g">⚇</span> Collaborators</a>
33
+ {/if}
31
34
</nav>
32
35
</aside>
33
36
MODIFY
src/test/java/de/workaround/git/AccessPolicyTest.java
+37 -2
@@ -1,5 +1,6 @@
1
1
package de.workaround.git;
2
2
3
+import java.util.Set;
3
4
import java.util.UUID;
4
5
5
6
import org.junit.jupiter.api.Test;
@@ -12,12 +13,16 @@
12
13
13
14
class AccessPolicyTest
14
15
{
15
- private final AccessPolicy policy = new AccessPolicy();
16
-
17
16
private final User owner = user();
18
17
19
18
private final User stranger = user();
20
19
20
+ private final User collaborator = user();
21
+
22
+ /** Policy whose collaborator lookup knows exactly one collaborator. */
23
+ private final AccessPolicy policy = new AccessPolicy(
24
+ (user, repository) -> Set.of(collaborator.id).contains(user.id));
25
+
21
26
@Test
22
27
void ownerReadsAndWritesOwnRepository()
23
28
{
@@ -48,6 +53,36 @@
48
53
assertFalse(policy.canWrite(stranger, repo));
49
54
}
50
55
56
+ @Test
57
+ void collaboratorReadsAndWritesPrivateRepository()
58
+ {
59
+ Repository repo = repo(owner, Repository.Visibility.PRIVATE);
60
+
61
+ assertTrue(policy.canRead(collaborator, repo));
62
+ assertTrue(policy.canWrite(collaborator, repo));
63
+ }
64
+
65
+ @Test
66
+ void collaboratorWritesPublicRepository()
67
+ {
68
+ Repository repo = repo(owner, Repository.Visibility.PUBLIC);
69
+
70
+ assertTrue(policy.canWrite(collaborator, repo));
71
+ }
72
+
73
+ @Test
74
+ void anonymousIsNeverTreatedAsCollaborator()
75
+ {
76
+ // a lookup that would blow up on null users must never be consulted for anonymous requests
77
+ AccessPolicy paranoid = new AccessPolicy((user, repository) -> {
78
+ throw new AssertionError("lookup must not be called for anonymous users");
79
+ });
80
+ Repository repo = repo(owner, Repository.Visibility.PRIVATE);
81
+
82
+ assertFalse(paranoid.canRead(null, repo));
83
+ assertFalse(paranoid.canWrite(null, repo));
84
+ }
85
+
51
86
private static User user()
52
87
{
53
88
User user = new User();
MODIFY
src/test/java/de/workaround/http/GitSmartHttpTest.java
+35 -0
@@ -22,6 +22,7 @@
22
22
import jakarta.transaction.Transactional;
23
23
24
24
import static org.junit.jupiter.api.Assertions.assertEquals;
25
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
25
26
import static org.junit.jupiter.api.Assertions.assertThrows;
26
27
import static org.junit.jupiter.api.Assertions.assertTrue;
27
28
@@ -118,6 +119,40 @@
118
119
assertEquals(before, mainRef(service.repositoryPath(repo)), "refs must not change on forbidden push");
119
120
}
120
121
122
+ @Inject
123
+ de.workaround.git.CollaboratorService collaboratorService;
124
+
125
+ @Test
126
+ void collaboratorClonesAndPushesPrivateRepository() throws Exception
127
+ {
128
+ User owner = persistUser();
129
+ User collaborator = persistUser();
130
+ Repository repo = createRepo(owner, "collab-push", Repository.Visibility.PRIVATE);
131
+ seedCommit(service.repositoryPath(repo));
132
+ addCollaborator(repo, collaborator);
133
+ ObjectId before = mainRef(service.repositoryPath(repo));
134
+ String token = createToken(collaborator);
135
+ var credentials = new UsernamePasswordCredentialsProvider(collaborator.username, token);
136
+
137
+ Path work = Files.createTempDirectory("collab-push");
138
+ try (Git git = cloneOver(httpUrl(owner, "collab-push"), work, credentials))
139
+ {
140
+ commitFile(git, work, "collab.txt");
141
+ git.push()
142
+ .setCredentialsProvider(credentials)
143
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
144
+ .call();
145
+ }
146
+
147
+ assertNotEquals(before, mainRef(service.repositoryPath(repo)), "collaborator push must advance main");
148
+ }
149
+
150
+ @Transactional
151
+ void addCollaborator(Repository repo, User user)
152
+ {
153
+ collaboratorService.add(repo.owner, repo, user.username);
154
+ }
155
+
121
156
@Test
122
157
void privateRepositoryRequiresAuthenticationForClone() throws Exception
123
158
{
MODIFY
src/test/java/de/workaround/ssh/SshGitAccessTest.java
+37 -0
@@ -161,6 +161,43 @@
161
161
assertEquals(before, mainRef(service.repositoryPath(repo)), "refs must not change on denied push");
162
162
}
163
163
164
+ @Inject
165
+ de.workaround.git.CollaboratorService collaboratorService;
166
+
167
+ @Test
168
+ void collaboratorClonesAndPushesPrivateRepositoryOverSsh() throws Exception
169
+ {
170
+ KeyPair ownerKey = generateKeyPair();
171
+ User owner = persistUserWithKey(ownerKey);
172
+ KeyPair collaboratorKey = generateKeyPair();
173
+ User collaborator = persistUserWithKey(collaboratorKey);
174
+ Repository repo = service.create(owner, "ssh-collab", Repository.Visibility.PRIVATE, null);
175
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
176
+ addCollaborator(repo, collaborator);
177
+ ObjectId before = mainRef(service.repositoryPath(repo));
178
+
179
+ Path work = Files.createTempDirectory("ssh-collab");
180
+ try (Git git = cloneOverSsh(sshUrl(owner, "ssh-collab"), work, collaboratorKey))
181
+ {
182
+ Files.writeString(work.resolve("collab.txt"), "via collaborator\n");
183
+ git.add().addFilepattern(".").call();
184
+ git.commit().setMessage("collaborator push").setSign(false)
185
+ .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
186
+ git.push()
187
+ .setTransportConfigCallback(SshGitAccessTest::configureTransport)
188
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
189
+ .call();
190
+ }
191
+
192
+ assertNotEquals(before, mainRef(service.repositoryPath(repo)), "collaborator push must advance main");
193
+ }
194
+
195
+ @Transactional
196
+ void addCollaborator(Repository repo, User user)
197
+ {
198
+ collaboratorService.add(repo.owner, repo, user.username);
199
+ }
200
+
164
201
@Test
165
202
void hostKeyIsPersistedAndStable() throws Exception
166
203
{
ADD
src/test/java/de/workaround/web/CollaboratorSettingsTest.java
+283 -0
@@ -0,0 +1,283 @@
1
+package de.workaround.web;
2
+
3
+import java.util.List;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.git.CollaboratorService;
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.RepositoryCollaborator;
12
+import de.workaround.model.User;
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import io.quarkus.test.security.TestSecurity;
15
+import jakarta.inject.Inject;
16
+import jakarta.transaction.Transactional;
17
+
18
+import static io.restassured.RestAssured.given;
19
+import static org.hamcrest.CoreMatchers.containsString;
20
+import static org.hamcrest.CoreMatchers.not;
21
+import static org.junit.jupiter.api.Assertions.assertEquals;
22
+
23
+/**
24
+ * Owner-only collaborator management under /repos/{owner}/{name}/settings/collaborators: the owner
25
+ * adds and removes local users by username, errors surface on the page, and non-owners get 403
26
+ * (404 when they cannot even see the repository).
27
+ */
28
+@QuarkusTest
29
+class CollaboratorSettingsTest
30
+{
31
+ private static final String OWNER = "collab-owner";
32
+
33
+ private static final String STRANGER = "collab-stranger";
34
+
35
+ private static final String MEMBER = "collab-member";
36
+
37
+ @Inject
38
+ GitRepositoryService service;
39
+
40
+ @Inject
41
+ CollaboratorService collaboratorService;
42
+
43
+ @Inject
44
+ User.Repo users;
45
+
46
+ @Test
47
+ @TestSecurity(user = OWNER)
48
+ void ownerSeesCollaboratorsPageAndAddsUser()
49
+ {
50
+ persistUser(MEMBER);
51
+ Repository repo = repoOwnedBy(OWNER, "collab-add", Repository.Visibility.PUBLIC);
52
+
53
+ given()
54
+ .when().get(settingsPath(repo))
55
+ .then().statusCode(200)
56
+ .body(containsString("Collaborators"));
57
+
58
+ given()
59
+ .redirects().follow(false)
60
+ .formParam("username", MEMBER)
61
+ .when().post(settingsPath(repo))
62
+ .then().statusCode(303);
63
+
64
+ given()
65
+ .when().get(settingsPath(repo))
66
+ .then().statusCode(200)
67
+ .body(containsString(MEMBER));
68
+
69
+ assertEquals(1, listCollaborators(repo).size());
70
+ }
71
+
72
+ @Test
73
+ @TestSecurity(user = OWNER)
74
+ void ownerRemovesCollaborator()
75
+ {
76
+ User member = persistUser(MEMBER);
77
+ Repository repo = repoOwnedBy(OWNER, "collab-remove", Repository.Visibility.PUBLIC);
78
+ addCollaborator(repo, member);
79
+
80
+ given()
81
+ .redirects().follow(false)
82
+ .when().post(settingsPath(repo) + "/" + MEMBER + "/remove")
83
+ .then().statusCode(303);
84
+
85
+ assertEquals(0, listCollaborators(repo).size());
86
+ given()
87
+ .when().get(settingsPath(repo))
88
+ .then().statusCode(200)
89
+ .body(not(containsString(MEMBER)));
90
+ }
91
+
92
+ @Test
93
+ @TestSecurity(user = OWNER)
94
+ void addingUnknownUsernameShowsError()
95
+ {
96
+ Repository repo = repoOwnedBy(OWNER, "collab-unknown", Repository.Visibility.PUBLIC);
97
+
98
+ given()
99
+ .redirects().follow(false)
100
+ .formParam("username", "does-not-exist")
101
+ .when().post(settingsPath(repo))
102
+ .then().statusCode(400)
103
+ .body(containsString("No user with that username exists."));
104
+ }
105
+
106
+ @Test
107
+ @TestSecurity(user = OWNER)
108
+ void addingExistingCollaboratorShowsError()
109
+ {
110
+ User member = persistUser(MEMBER);
111
+ Repository repo = repoOwnedBy(OWNER, "collab-dup", Repository.Visibility.PUBLIC);
112
+ addCollaborator(repo, member);
113
+
114
+ given()
115
+ .redirects().follow(false)
116
+ .formParam("username", MEMBER)
117
+ .when().post(settingsPath(repo))
118
+ .then().statusCode(400)
119
+ .body(containsString("That user is already a collaborator."));
120
+
121
+ assertEquals(1, listCollaborators(repo).size());
122
+ }
123
+
124
+ @Test
125
+ @TestSecurity(user = OWNER)
126
+ void addingTheOwnerShowsError()
127
+ {
128
+ Repository repo = repoOwnedBy(OWNER, "collab-self", Repository.Visibility.PUBLIC);
129
+
130
+ given()
131
+ .redirects().follow(false)
132
+ .formParam("username", OWNER)
133
+ .when().post(settingsPath(repo))
134
+ .then().statusCode(400)
135
+ .body(containsString("The owner cannot be added as a collaborator."));
136
+
137
+ assertEquals(0, listCollaborators(repo).size());
138
+ }
139
+
140
+ @Test
141
+ @TestSecurity(user = STRANGER)
142
+ void nonOwnerGets403OnPublicRepositorySettings()
143
+ {
144
+ persistUser(STRANGER);
145
+ Repository repo = repoOwnedBy(OWNER, "collab-403", Repository.Visibility.PUBLIC);
146
+
147
+ given()
148
+ .when().get(settingsPath(repo))
149
+ .then().statusCode(403);
150
+
151
+ given()
152
+ .redirects().follow(false)
153
+ .formParam("username", STRANGER)
154
+ .when().post(settingsPath(repo))
155
+ .then().statusCode(403);
156
+
157
+ assertEquals(0, listCollaborators(repo).size());
158
+ }
159
+
160
+ @Test
161
+ @TestSecurity(user = MEMBER)
162
+ void collaboratorCannotManageCollaborators()
163
+ {
164
+ User member = persistUser(MEMBER);
165
+ Repository repo = repoOwnedBy(OWNER, "collab-member-403", Repository.Visibility.PUBLIC);
166
+ addCollaborator(repo, member);
167
+
168
+ given()
169
+ .when().get(settingsPath(repo))
170
+ .then().statusCode(403);
171
+ }
172
+
173
+ @Test
174
+ @TestSecurity(user = STRANGER)
175
+ void privateRepositorySettingsAreHiddenFromStrangers()
176
+ {
177
+ persistUser(STRANGER);
178
+ Repository repo = repoOwnedBy(OWNER, "collab-hidden", Repository.Visibility.PRIVATE);
179
+
180
+ given()
181
+ .when().get(settingsPath(repo))
182
+ .then().statusCode(404);
183
+ }
184
+
185
+ @Test
186
+ void anonymousGets403OnPublicRepositorySettings()
187
+ {
188
+ Repository repo = repoOwnedBy(OWNER, "collab-anon", Repository.Visibility.PUBLIC);
189
+
190
+ given()
191
+ .when().get(settingsPath(repo))
192
+ .then().statusCode(403);
193
+ }
194
+
195
+ @Test
196
+ @TestSecurity(user = MEMBER)
197
+ void collaboratorReadsPrivateRepositoryInUi()
198
+ {
199
+ User member = persistUser(MEMBER);
200
+ Repository repo = repoOwnedBy(OWNER, "collab-private-read", Repository.Visibility.PRIVATE);
201
+ addCollaborator(repo, member);
202
+
203
+ given()
204
+ .when().get("/repos/" + OWNER + "/" + repo.name)
205
+ .then().statusCode(200);
206
+ }
207
+
208
+ @Test
209
+ @TestSecurity(user = STRANGER)
210
+ void strangerStillCannotReadPrivateRepositoryInUi()
211
+ {
212
+ persistUser(STRANGER);
213
+ Repository repo = repoOwnedBy(OWNER, "collab-private-hidden", Repository.Visibility.PRIVATE);
214
+
215
+ given()
216
+ .when().get("/repos/" + OWNER + "/" + repo.name)
217
+ .then().statusCode(404);
218
+ }
219
+
220
+ @Test
221
+ @TestSecurity(user = OWNER)
222
+ void ownerSeesSettingsLinkInSidebar()
223
+ {
224
+ Repository repo = repoOwnedBy(OWNER, "collab-nav", Repository.Visibility.PUBLIC);
225
+
226
+ given()
227
+ .when().get("/repos/" + OWNER + "/" + repo.name)
228
+ .then().statusCode(200)
229
+ .body(containsString("settings/collaborators"));
230
+ }
231
+
232
+ @Test
233
+ @TestSecurity(user = STRANGER)
234
+ void nonOwnerDoesNotSeeSettingsLinkInSidebar()
235
+ {
236
+ persistUser(STRANGER);
237
+ Repository repo = repoOwnedBy(OWNER, "collab-nav-hidden", Repository.Visibility.PUBLIC);
238
+
239
+ given()
240
+ .when().get("/repos/" + OWNER + "/" + repo.name)
241
+ .then().statusCode(200)
242
+ .body(not(containsString("settings/collaborators")));
243
+ }
244
+
245
+ private static String settingsPath(Repository repo)
246
+ {
247
+ return "/repos/" + OWNER + "/" + repo.name + "/settings/collaborators";
248
+ }
249
+
250
+ private Repository repoOwnedBy(String username, String name, Repository.Visibility visibility)
251
+ {
252
+ User owner = persistUser(username);
253
+ return service.create(owner, name + "-" + UUID.randomUUID().toString().substring(0, 8), visibility, null);
254
+ }
255
+
256
+ @Transactional
257
+ void addCollaborator(Repository repo, User member)
258
+ {
259
+ collaboratorService.add(repo.owner, repo, member.username);
260
+ }
261
+
262
+ @Transactional
263
+ List<RepositoryCollaborator> listCollaborators(Repository repo)
264
+ {
265
+ return collaboratorService.list(repo);
266
+ }
267
+
268
+ @Transactional
269
+ User persistUser(String name)
270
+ {
271
+ User existing = users.findByOidcSubOptional(name).orElse(null);
272
+ if (existing != null)
273
+ {
274
+ return existing;
275
+ }
276
+ User user = new User();
277
+ user.oidcSub = name;
278
+ user.username = name;
279
+ user.persist();
280
+ return user;
281
+ }
282
+
283
+}