gitshark

Clone repository

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

← Commits

✨ (merge-requests): Add per-repository merge requests with in-core diff & merge

0f9d4ba452bc819a64927c3f842f839c1c11359a · Phillip Souza Furtner · 2026-07-08T08:35:59Z

Changes

18 files changed, +1590 -4

MODIFY README.md +6 -2
diff --git a/README.md b/README.md
index 9e2c14b..ddeb2e2 100644
--- a/README.md
+++ b/README.md
@@ -14,10 +14,14 @@
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: 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), commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues tab navigation that preserves the selected ref, 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
17 +- Web UI: 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), commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues / Merge requests tab navigation that preserves the selected ref, 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 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
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
22 +- Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and shared tab bar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
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
21 25 - 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)
22 26 - Single access policy on all paths: owner read/write, public world-readable, private owner-only
23 27 - **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
@@ -44,7 +48,7 @@
44 48
45 49 Inbound activities must carry a valid HTTP Signature from an **allowlisted** peer; outbound fetches
46 50 are HTTPS-only, allowlist-bound, and SSRF-guarded (no private/loopback/link-local targets). Issues
47 -and pull/merge requests are **not** federated (git-shark has no such features yet).
51 +and merge requests exist locally but are **not** federated yet.
48 52
49 53 > Enabling federation publishes **permanent** actor IDs derived from `base-url`. Set a real,
50 54 > stable, non-loopback HTTPS origin before turning it on; git-shark refuses to emit actor documents
ADD src/main/java/de/workaround/git/GitMergeService.java +277 -0
diff --git a/src/main/java/de/workaround/git/GitMergeService.java b/src/main/java/de/workaround/git/GitMergeService.java
new file mode 100644
index 0000000..f3e9b44
--- /dev/null
+++ b/src/main/java/de/workaround/git/GitMergeService.java
@@ -0,0 +1,277 @@
1 +package de.workaround.git;
2 +
3 +import java.io.ByteArrayOutputStream;
4 +import java.io.IOException;
5 +import java.io.UncheckedIOException;
6 +import java.nio.charset.StandardCharsets;
7 +import java.nio.file.Path;
8 +import java.util.ArrayList;
9 +import java.util.List;
10 +import java.util.Optional;
11 +
12 +import org.eclipse.jgit.diff.DiffEntry;
13 +import org.eclipse.jgit.diff.DiffFormatter;
14 +import org.eclipse.jgit.lib.CommitBuilder;
15 +import org.eclipse.jgit.lib.Constants;
16 +import org.eclipse.jgit.lib.ObjectId;
17 +import org.eclipse.jgit.lib.ObjectInserter;
18 +import org.eclipse.jgit.lib.ObjectReader;
19 +import org.eclipse.jgit.lib.PersonIdent;
20 +import org.eclipse.jgit.lib.RefUpdate;
21 +import org.eclipse.jgit.lib.Repository;
22 +import org.eclipse.jgit.merge.MergeStrategy;
23 +import org.eclipse.jgit.merge.ThreeWayMerger;
24 +import org.eclipse.jgit.revwalk.RevCommit;
25 +import org.eclipse.jgit.revwalk.RevWalk;
26 +import org.eclipse.jgit.revwalk.filter.RevFilter;
27 +import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
28 +import org.eclipse.jgit.treewalk.AbstractTreeIterator;
29 +import org.eclipse.jgit.treewalk.CanonicalTreeParser;
30 +import org.eclipse.jgit.treewalk.EmptyTreeIterator;
31 +import org.eclipse.jgit.util.io.DisabledOutputStream;
32 +
33 +import jakarta.enterprise.context.ApplicationScoped;
34 +
35 +/**
36 + * Diffing and merging of branches inside a bare repository, used by merge requests. Everything runs
37 + * in-core (no working tree), so it works against the same bare repos the transports serve. Nothing is
38 + * duplicated into the database — the diff is always computed live from git.
39 + */
40 +@ApplicationScoped
41 +public class GitMergeService
42 +{
43 + /** A single line of a unified diff, tagged so the UI can colour it without re-parsing the patch. */
44 + public record DiffLine(String type, String text)
45 + {
46 + }
47 +
48 + /** One changed file: its path, the {@link DiffEntry.ChangeType} name, the diff lines and add/del counts. */
49 + public record FileDiff(String path, String changeType, List<DiffLine> lines, int additions, int deletions)
50 + {
51 + }
52 +
53 + /** The full set of changes the source branch would bring into the target (merge-base..source). */
54 + public record DiffView(List<FileDiff> files, int additions, int deletions)
55 + {
56 + }
57 +
58 + public enum MergeResult
59 + {
60 + /** The source branch was merged (or fast-forwarded) into the target and the target ref advanced. */
61 + MERGED,
62 + /** The three-way merge produced conflicts; nothing was written. */
63 + CONFLICT,
64 + /** The source branch is already contained in the target; there is nothing to merge. */
65 + UP_TO_DATE,
66 + /** One of the branches does not exist. */
67 + MISSING_BRANCH
68 + }
69 +
70 + public boolean branchExists(Path barePath, String branch)
71 + {
72 + try (Repository repo = open(barePath))
73 + {
74 + return repo.resolve(Constants.R_HEADS + branch) != null;
75 + }
76 + catch (IOException e)
77 + {
78 + throw new UncheckedIOException(e);
79 + }
80 + }
81 +
82 + /**
83 + * Computes the changes the source branch introduces on top of the target, i.e. the diff from the merge base
84 + * of the two branches to the tip of the source (three-dot semantics). Returns empty if either branch is missing.
85 + */
86 + public Optional<DiffView> diff(Path barePath, String targetBranch, String sourceBranch)
87 + {
88 + try (Repository repo = open(barePath); RevWalk walk = new RevWalk(repo))
89 + {
90 + ObjectId targetId = repo.resolve(Constants.R_HEADS + targetBranch);
91 + ObjectId sourceId = repo.resolve(Constants.R_HEADS + sourceBranch);
92 + if (targetId == null || sourceId == null)
93 + {
94 + return Optional.empty();
95 + }
96 + RevCommit source = walk.parseCommit(sourceId);
97 + RevCommit base = mergeBase(repo, targetId, sourceId);
98 +
99 + List<FileDiff> files = new ArrayList<>();
100 + int totalAdd = 0;
101 + int totalDel = 0;
102 + try (ObjectReader reader = repo.newObjectReader())
103 + {
104 + AbstractTreeIterator oldTree = base == null ? new EmptyTreeIterator() : tree(reader, base);
105 + AbstractTreeIterator newTree = tree(reader, source);
106 + List<DiffEntry> entries;
107 + try (DiffFormatter scanner = new DiffFormatter(DisabledOutputStream.INSTANCE))
108 + {
109 + scanner.setRepository(repo);
110 + entries = scanner.scan(oldTree, newTree);
111 + }
112 + for (DiffEntry entry : entries)
113 + {
114 + FileDiff file = formatEntry(repo, entry);
115 + files.add(file);
116 + totalAdd += file.additions();
117 + totalDel += file.deletions();
118 + }
119 + }
120 + return Optional.of(new DiffView(files, totalAdd, totalDel));
121 + }
122 + catch (IOException e)
123 + {
124 + throw new UncheckedIOException(e);
125 + }
126 + }
127 +
128 + /**
129 + * Merges the source branch into the target branch and advances the target ref. Fast-forwards when possible,
130 + * otherwise records a two-parent merge commit authored by the given identity. Runs entirely in-core, so no
131 + * working tree is required. Idempotent-ish: an already-merged source reports {@link MergeResult#UP_TO_DATE}.
132 + */
133 + public MergeResult merge(Path barePath, String targetBranch, String sourceBranch, String committerName,
134 + String committerEmail, String message)
135 + {
136 + try (Repository repo = open(barePath); RevWalk walk = new RevWalk(repo))
137 + {
138 + ObjectId targetId = repo.resolve(Constants.R_HEADS + targetBranch);
139 + ObjectId sourceId = repo.resolve(Constants.R_HEADS + sourceBranch);
140 + if (targetId == null || sourceId == null)
141 + {
142 + return MergeResult.MISSING_BRANCH;
143 + }
144 + RevCommit target = walk.parseCommit(targetId);
145 + RevCommit source = walk.parseCommit(sourceId);
146 +
147 + if (walk.isMergedInto(source, target))
148 + {
149 + return MergeResult.UP_TO_DATE;
150 + }
151 + if (walk.isMergedInto(target, source))
152 + {
153 + updateRef(repo, targetBranch, targetId, sourceId, "fast-forward " + sourceBranch);
154 + return MergeResult.MERGED;
155 + }
156 +
157 + ThreeWayMerger merger = MergeStrategy.RECURSIVE.newMerger(repo, true);
158 + if (!merger.merge(target, source))
159 + {
160 + return MergeResult.CONFLICT;
161 + }
162 + ObjectId mergedTree = merger.getResultTreeId();
163 + ObjectId mergeCommit;
164 + try (ObjectInserter inserter = repo.newObjectInserter())
165 + {
166 + CommitBuilder builder = new CommitBuilder();
167 + builder.setTreeId(mergedTree);
168 + builder.setParentIds(target, source);
169 + PersonIdent ident = new PersonIdent(committerName, committerEmail);
170 + builder.setAuthor(ident);
171 + builder.setCommitter(ident);
172 + builder.setMessage(message);
173 + mergeCommit = inserter.insert(builder);
174 + inserter.flush();
175 + }
176 + updateRef(repo, targetBranch, targetId, mergeCommit, message);
177 + return MergeResult.MERGED;
178 + }
179 + catch (IOException e)
180 + {
181 + throw new UncheckedIOException(e);
182 + }
183 + }
184 +
185 + private static FileDiff formatEntry(Repository repo, DiffEntry entry) throws IOException
186 + {
187 + ByteArrayOutputStream out = new ByteArrayOutputStream();
188 + try (DiffFormatter formatter = new DiffFormatter(out))
189 + {
190 + formatter.setRepository(repo);
191 + formatter.format(entry);
192 + }
193 + String patch = out.toString(StandardCharsets.UTF_8);
194 + List<DiffLine> lines = new ArrayList<>();
195 + int additions = 0;
196 + int deletions = 0;
197 + String[] raw = patch.split("\n", -1);
198 + for (int i = 0; i < raw.length; i++)
199 + {
200 + String line = raw[i];
201 + // a trailing empty element from the final newline is not a real line
202 + if (i == raw.length - 1 && line.isEmpty())
203 + {
204 + break;
205 + }
206 + String type;
207 + if (line.startsWith("@@"))
208 + {
209 + type = "hunk";
210 + }
211 + else if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff ")
212 + || line.startsWith("index ") || line.startsWith("new file") || line.startsWith("deleted file")
213 + || line.startsWith("old mode") || line.startsWith("new mode") || line.startsWith("similarity ")
214 + || line.startsWith("rename ") || line.startsWith("\\"))
215 + {
216 + type = "meta";
217 + }
218 + else if (line.startsWith("+"))
219 + {
220 + type = "add";
221 + additions++;
222 + }
223 + else if (line.startsWith("-"))
224 + {
225 + type = "del";
226 + deletions++;
227 + }
228 + else
229 + {
230 + type = "context";
231 + }
232 + lines.add(new DiffLine(type, line));
233 + }
234 + String path = entry.getChangeType() == DiffEntry.ChangeType.DELETE ? entry.getOldPath() : entry.getNewPath();
235 + return new FileDiff(path, entry.getChangeType().name(), lines, additions, deletions);
236 + }
237 +
238 + private static RevCommit mergeBase(Repository repo, ObjectId a, ObjectId b) throws IOException
239 + {
240 + try (RevWalk walk = new RevWalk(repo))
241 + {
242 + walk.setRevFilter(RevFilter.MERGE_BASE);
243 + walk.markStart(walk.parseCommit(a));
244 + walk.markStart(walk.parseCommit(b));
245 + RevCommit base = walk.next();
246 + return base == null ? null : walk.parseCommit(base);
247 + }
248 + }
249 +
250 + private static AbstractTreeIterator tree(ObjectReader reader, RevCommit commit) throws IOException
251 + {
252 + CanonicalTreeParser parser = new CanonicalTreeParser();
253 + parser.reset(reader, commit.getTree());
254 + return parser;
255 + }
256 +
257 + private static void updateRef(Repository repo, String branch, ObjectId oldId, ObjectId newId, String logMessage)
258 + throws IOException
259 + {
260 + RefUpdate update = repo.updateRef(Constants.R_HEADS + branch);
261 + update.setNewObjectId(newId);
262 + update.setExpectedOldObjectId(oldId);
263 + update.setRefLogMessage(logMessage, false);
264 + RefUpdate.Result result = update.update();
265 + if (result != RefUpdate.Result.FAST_FORWARD && result != RefUpdate.Result.FORCED
266 + && result != RefUpdate.Result.NEW && result != RefUpdate.Result.NO_CHANGE)
267 + {
268 + throw new IllegalStateException("Failed to advance " + branch + ": " + result);
269 + }
270 + }
271 +
272 + private static Repository open(Path barePath) throws IOException
273 + {
274 + return new FileRepositoryBuilder().setGitDir(barePath.toFile()).setMustExist(true).build();
275 + }
276 +
277 +}
ADD src/main/java/de/workaround/git/InvalidMergeRequestException.java +10 -0
diff --git a/src/main/java/de/workaround/git/InvalidMergeRequestException.java b/src/main/java/de/workaround/git/InvalidMergeRequestException.java
new file mode 100644
index 0000000..832bdda
--- /dev/null
+++ b/src/main/java/de/workaround/git/InvalidMergeRequestException.java
@@ -0,0 +1,10 @@
1 +package de.workaround.git;
2 +
3 +/** Thrown when a merge request is rejected for invalid input or state, e.g. a blank title or a merge conflict. */
4 +public class InvalidMergeRequestException extends RuntimeException
5 +{
6 + public InvalidMergeRequestException(String message)
7 + {
8 + super(message);
9 + }
10 +}
ADD src/main/java/de/workaround/git/MergeRequestService.java +183 -0
diff --git a/src/main/java/de/workaround/git/MergeRequestService.java b/src/main/java/de/workaround/git/MergeRequestService.java
new file mode 100644
index 0000000..213e93c
--- /dev/null
+++ b/src/main/java/de/workaround/git/MergeRequestService.java
@@ -0,0 +1,183 @@
1 +package de.workaround.git;
2 +
3 +import java.nio.file.Path;
4 +import java.time.Instant;
5 +import java.util.List;
6 +import java.util.Optional;
7 +import java.util.UUID;
8 +
9 +import de.workaround.model.MergeRequest;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.User;
12 +import jakarta.enterprise.context.ApplicationScoped;
13 +import jakarta.inject.Inject;
14 +import jakarta.transaction.Transactional;
15 +
16 +/**
17 + * Manages per-repository merge requests. Reading follows the repository's read-visibility rule (enforced by
18 + * callers); creating, merging and closing require write access, i.e. repository ownership. The proposed changes
19 + * are never stored — they are diffed live from git through {@link GitMergeService}.
20 + */
21 +@ApplicationScoped
22 +public class MergeRequestService
23 +{
24 + @Inject
25 + MergeRequest.Repo mergeRequests;
26 +
27 + @Inject
28 + AccessPolicy accessPolicy;
29 +
30 + @Inject
31 + GitRepositoryService repositories;
32 +
33 + @Inject
34 + GitMergeService gitMerge;
35 +
36 + @Transactional
37 + public MergeRequest create(User actor, Repository repository, String title, String description,
38 + String sourceBranch, String targetBranch)
39 + {
40 + requireWrite(actor, repository);
41 + String trimmedTitle = title == null ? "" : title.strip();
42 + if (trimmedTitle.isEmpty())
43 + {
44 + throw new InvalidMergeRequestException("Merge request title must not be empty");
45 + }
46 + String source = normalizeBranch(sourceBranch);
47 + String target = normalizeBranch(targetBranch);
48 + if (source.isEmpty() || target.isEmpty())
49 + {
50 + throw new InvalidMergeRequestException("Source and target branch must be provided");
51 + }
52 + if (source.equals(target))
53 + {
54 + throw new InvalidMergeRequestException("Source and target branch must differ");
55 + }
56 + Path bare = repositories.repositoryPath(repository);
57 + if (!gitMerge.branchExists(bare, source))
58 + {
59 + throw new InvalidMergeRequestException("Unknown source branch: " + source);
60 + }
61 + if (!gitMerge.branchExists(bare, target))
62 + {
63 + throw new InvalidMergeRequestException("Unknown target branch: " + target);
64 + }
65 +
66 + MergeRequest mr = new MergeRequest();
67 + mr.repository = repository;
68 + mr.author = actor;
69 + mr.number = mergeRequests.maxNumber(repository) + 1;
70 + mr.title = trimmedTitle;
71 + mr.description = description == null || description.isBlank() ? null : description.strip();
72 + mr.sourceBranch = source;
73 + mr.targetBranch = target;
74 + mr.status = MergeRequest.Status.OPEN;
75 + mr.persist();
76 + return mr;
77 + }
78 +
79 + public List<MergeRequest> list(Repository repository)
80 + {
81 + return mergeRequests.findByRepository(repository);
82 + }
83 +
84 + /** Number of open merge requests in the repository; merged and closed ones are excluded. */
85 + public long countOpen(Repository repository)
86 + {
87 + return mergeRequests.countOpen(repository);
88 + }
89 +
90 + public Optional<MergeRequest> find(Repository repository, UUID id)
91 + {
92 + return mergeRequests.findByRepositoryAndId(repository, id);
93 + }
94 +
95 + /** The live diff (merge-base..source) of the proposed change, or empty if a branch has since disappeared. */
96 + public Optional<GitMergeService.DiffView> diff(MergeRequest mr)
97 + {
98 + Path bare = repositories.repositoryPath(mr.repository);
99 + return gitMerge.diff(bare, mr.targetBranch, mr.sourceBranch);
100 + }
101 +
102 + /**
103 + * Merges the request's source branch into its target and moves the request to MERGED. A merge conflict or a
104 + * source already contained in the target is rejected with {@link InvalidMergeRequestException}; only OPEN
105 + * requests can be merged.
106 + */
107 + @Transactional
108 + public GitMergeService.MergeResult merge(User actor, MergeRequest mr)
109 + {
110 + requireWrite(actor, mr.repository);
111 + MergeRequest managed = mergeRequests.findById(mr.id);
112 + if (managed == null)
113 + {
114 + throw new InvalidMergeRequestException("Merge request no longer exists");
115 + }
116 + if (managed.status != MergeRequest.Status.OPEN)
117 + {
118 + throw new InvalidMergeRequestException("Only open merge requests can be merged");
119 + }
120 + Path bare = repositories.repositoryPath(managed.repository);
121 + String message = "Merge branch '" + managed.sourceBranch + "' into " + managed.targetBranch
122 + + " (!" + managed.number + ")";
123 + GitMergeService.MergeResult result = gitMerge.merge(bare, managed.targetBranch, managed.sourceBranch,
124 + committerName(actor), committerEmail(actor), message);
125 + switch (result)
126 + {
127 + case MERGED, UP_TO_DATE ->
128 + {
129 + managed.status = MergeRequest.Status.MERGED;
130 + managed.mergedAt = Instant.now();
131 + }
132 + case CONFLICT -> throw new InvalidMergeRequestException(
133 + "Cannot merge automatically: " + managed.sourceBranch + " conflicts with " + managed.targetBranch);
134 + case MISSING_BRANCH -> throw new InvalidMergeRequestException(
135 + "Source or target branch no longer exists");
136 + }
137 + return result;
138 + }
139 +
140 + /** Moves an open merge request to CLOSED without merging. */
141 + @Transactional
142 + public void close(User actor, MergeRequest mr)
143 + {
144 + requireWrite(actor, mr.repository);
145 + MergeRequest managed = mergeRequests.findById(mr.id);
146 + if (managed != null && managed.status == MergeRequest.Status.OPEN)
147 + {
148 + managed.status = MergeRequest.Status.CLOSED;
149 + }
150 + }
151 +
152 + private void requireWrite(User actor, Repository repository)
153 + {
154 + if (!accessPolicy.canWrite(actor, repository))
155 + {
156 + throw new ForbiddenOperationException("Only the repository owner can manage merge requests");
157 + }
158 + }
159 +
160 + private static String normalizeBranch(String branch)
161 + {
162 + return branch == null ? "" : branch.strip();
163 + }
164 +
165 + private static String committerName(User actor)
166 + {
167 + if (actor.displayName != null && !actor.displayName.isBlank())
168 + {
169 + return actor.displayName;
170 + }
171 + return actor.username;
172 + }
173 +
174 + private static String committerEmail(User actor)
175 + {
176 + if (actor.email != null && !actor.email.isBlank())
177 + {
178 + return actor.email;
179 + }
180 + return actor.username + "@git-shark.local";
181 + }
182 +
183 +}
ADD src/main/java/de/workaround/model/MergeRequest.java +90 -0
diff --git a/src/main/java/de/workaround/model/MergeRequest.java b/src/main/java/de/workaround/model/MergeRequest.java
new file mode 100644
index 0000000..5a56315
--- /dev/null
+++ b/src/main/java/de/workaround/model/MergeRequest.java
@@ -0,0 +1,90 @@
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.EnumType;
15 +import jakarta.persistence.Enumerated;
16 +import jakarta.persistence.GeneratedValue;
17 +import jakarta.persistence.GenerationType;
18 +import jakarta.persistence.Id;
19 +import jakarta.persistence.ManyToOne;
20 +import jakarta.persistence.Table;
21 +
22 +/**
23 + * A request to merge one branch ({@link #sourceBranch}) into another ({@link #targetBranch}) within a single
24 + * repository. The proposed changes are diffed live from git; only the request's metadata and lifecycle
25 + * ({@link Status}) live in the database. Owned by the repository and removed with it (DB-level ON DELETE CASCADE).
26 + */
27 +@Entity
28 +@Table(name = "merge_requests")
29 +public class MergeRequest implements PanacheEntity.Managed
30 +{
31 + @Id
32 + @GeneratedValue(strategy = GenerationType.UUID)
33 + public UUID id;
34 +
35 + @ManyToOne(optional = false)
36 + public Repository repository;
37 +
38 + @ManyToOne(optional = false)
39 + public User author;
40 +
41 + /** Per-repository, human-facing number (#1, #2, ...) assigned on creation; unique within the repository. */
42 + public int number;
43 +
44 + public String title;
45 +
46 + public String description;
47 +
48 + public String sourceBranch;
49 +
50 + public String targetBranch;
51 +
52 + @Enumerated(EnumType.STRING)
53 + public Status status = Status.OPEN;
54 +
55 + public Instant createdAt = Instant.now();
56 +
57 + /** Set when the request is successfully merged; null while OPEN or when CLOSED without merging. */
58 + public Instant mergedAt;
59 +
60 + public enum Status
61 + {
62 + OPEN("Open"),
63 + MERGED("Merged"),
64 + CLOSED("Closed");
65 +
66 + /** Human-readable label for the UI; the enum name is the stable value used in forms and the DB. */
67 + public final String label;
68 +
69 + Status(String label)
70 + {
71 + this.label = label;
72 + }
73 + }
74 +
75 + public interface Repo extends PanacheRepository.Managed<MergeRequest, UUID>
76 + {
77 + @HQL("select mr from MergeRequest mr join fetch mr.author where mr.repository = :repository order by mr.createdAt desc")
78 + List<MergeRequest> findByRepository(Repository repository);
79 +
80 + @Find
81 + Optional<MergeRequest> findByRepositoryAndId(Repository repository, UUID id);
82 +
83 + @HQL("select count(mr) from MergeRequest mr where mr.repository = :repository and mr.status = OPEN")
84 + long countOpen(Repository repository);
85 +
86 + @HQL("select coalesce(max(mr.number), 0) from MergeRequest mr where mr.repository = :repository")
87 + int maxNumber(Repository repository);
88 + }
89 +
90 +}
ADD src/main/java/de/workaround/web/InvalidMergeRequestExceptionMapper.java +25 -0
diff --git a/src/main/java/de/workaround/web/InvalidMergeRequestExceptionMapper.java b/src/main/java/de/workaround/web/InvalidMergeRequestExceptionMapper.java
new file mode 100644
index 0000000..4baaa4f
--- /dev/null
+++ b/src/main/java/de/workaround/web/InvalidMergeRequestExceptionMapper.java
@@ -0,0 +1,25 @@
1 +package de.workaround.web;
2 +
3 +import de.workaround.git.InvalidMergeRequestException;
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 InvalidMergeRequestException} to HTTP 400, so invalid merge-request input or state
11 + * (blank title, unknown branch, merge conflict) surfaces as a clean bad request instead of a generic 500.
12 + * Mirrors {@link InvalidIssueExceptionMapper}.
13 + */
14 +@Provider
15 +public class InvalidMergeRequestExceptionMapper implements ExceptionMapper<InvalidMergeRequestException>
16 +{
17 + @Override
18 + public Response toResponse(InvalidMergeRequestException exception)
19 + {
20 + return Response.status(Response.Status.BAD_REQUEST)
21 + .entity(exception.getMessage())
22 + .type(MediaType.TEXT_PLAIN)
23 + .build();
24 + }
25 +}
ADD src/main/java/de/workaround/web/MergeRequestResource.java +168 -0
diff --git a/src/main/java/de/workaround/web/MergeRequestResource.java b/src/main/java/de/workaround/web/MergeRequestResource.java
new file mode 100644
index 0000000..8320c9c
--- /dev/null
+++ b/src/main/java/de/workaround/web/MergeRequestResource.java
@@ -0,0 +1,168 @@
1 +package de.workaround.web;
2 +
3 +import java.net.URI;
4 +import java.nio.file.Path;
5 +import java.util.List;
6 +import java.util.UUID;
7 +
8 +import de.workaround.account.CurrentUser;
9 +import de.workaround.git.AccessPolicy;
10 +import de.workaround.git.ForbiddenOperationException;
11 +import de.workaround.git.GitBrowseService;
12 +import de.workaround.git.GitMergeService;
13 +import de.workaround.git.GitRepositoryService;
14 +import de.workaround.git.MergeRequestService;
15 +import de.workaround.model.MergeRequest;
16 +import de.workaround.model.Repository;
17 +import de.workaround.model.User;
18 +import io.quarkus.qute.CheckedTemplate;
19 +import io.quarkus.qute.TemplateInstance;
20 +import jakarta.inject.Inject;
21 +import jakarta.ws.rs.Consumes;
22 +import jakarta.ws.rs.FormParam;
23 +import jakarta.ws.rs.GET;
24 +import jakarta.ws.rs.NotFoundException;
25 +import jakarta.ws.rs.POST;
26 +import jakarta.ws.rs.PathParam;
27 +import jakarta.ws.rs.Produces;
28 +import jakarta.ws.rs.core.MediaType;
29 +import jakarta.ws.rs.core.Response;
30 +
31 +@jakarta.ws.rs.Path("/repos/{owner}/{name}/merge-requests")
32 +@Produces(MediaType.TEXT_HTML)
33 +public class MergeRequestResource
34 +{
35 + @CheckedTemplate
36 + static class Templates
37 + {
38 + static native TemplateInstance mergeRequests(Repository repo, boolean owner, List<MergeRequest> open,
39 + List<MergeRequest> closed);
40 +
41 + static native TemplateInstance newMergeRequest(Repository repo, List<String> branches, String defaultBranch);
42 +
43 + static native TemplateInstance mergeRequest(Repository repo, boolean owner, MergeRequest mr,
44 + GitMergeService.DiffView diff);
45 + }
46 +
47 + @Inject
48 + CurrentUser currentUser;
49 +
50 + @Inject
51 + GitRepositoryService service;
52 +
53 + @Inject
54 + GitBrowseService browse;
55 +
56 + @Inject
57 + AccessPolicy accessPolicy;
58 +
59 + @Inject
60 + MergeRequestService mergeRequestService;
61 +
62 + @GET
63 + public TemplateInstance list(@PathParam("owner") String owner, @PathParam("name") String name)
64 + {
65 + Repository repo = requireReadable(owner, name);
66 + List<MergeRequest> all = mergeRequestService.list(repo);
67 + List<MergeRequest> open = all.stream().filter(mr -> mr.status == MergeRequest.Status.OPEN).toList();
68 + List<MergeRequest> closed = all.stream().filter(mr -> mr.status != MergeRequest.Status.OPEN).toList();
69 + return Templates.mergeRequests(repo, isOwner(repo), open, closed);
70 + }
71 +
72 + @GET
73 + @jakarta.ws.rs.Path("new")
74 + public TemplateInstance newForm(@PathParam("owner") String owner, @PathParam("name") String name)
75 + {
76 + Repository repo = requireReadable(owner, name);
77 + if (!accessPolicy.canWrite(currentUser.get(), repo))
78 + {
79 + throw new ForbiddenOperationException("Only the repository owner can open merge requests");
80 + }
81 + Path path = service.repositoryPath(repo);
82 + List<String> branches = browse.branches(path).stream().map(GitBrowseService.BranchInfo::name).toList();
83 + String defaultBranch = browse.isEmpty(path) ? null : browse.defaultBranch(path);
84 + return Templates.newMergeRequest(repo, branches, defaultBranch);
85 + }
86 +
87 + @POST
88 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
89 + public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
90 + @FormParam("title") String title, @FormParam("description") String description,
91 + @FormParam("sourceBranch") String sourceBranch, @FormParam("targetBranch") String targetBranch)
92 + {
93 + Repository repo = requireReadable(owner, name);
94 + MergeRequest mr = mergeRequestService.create(currentUser.require(), repo, title, description, sourceBranch,
95 + targetBranch);
96 + return Response.seeOther(detailUri(repo, mr.id)).build();
97 + }
98 +
99 + @GET
100 + @jakarta.ws.rs.Path("{id}")
101 + public TemplateInstance detail(@PathParam("owner") String owner, @PathParam("name") String name,
102 + @PathParam("id") String id)
103 + {
104 + Repository repo = requireReadable(owner, name);
105 + MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
106 + GitMergeService.DiffView diff = mergeRequestService.diff(mr).orElse(null);
107 + return Templates.mergeRequest(repo, isOwner(repo), mr, diff);
108 + }
109 +
110 + @POST
111 + @jakarta.ws.rs.Path("{id}/merge")
112 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
113 + public Response merge(@PathParam("owner") String owner, @PathParam("name") String name,
114 + @PathParam("id") String id)
115 + {
116 + Repository repo = requireReadable(owner, name);
117 + MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
118 + mergeRequestService.merge(currentUser.require(), mr);
119 + return Response.seeOther(detailUri(repo, mr.id)).build();
120 + }
121 +
122 + @POST
123 + @jakarta.ws.rs.Path("{id}/close")
124 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
125 + public Response close(@PathParam("owner") String owner, @PathParam("name") String name,
126 + @PathParam("id") String id)
127 + {
128 + Repository repo = requireReadable(owner, name);
129 + MergeRequest mr = mergeRequestService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
130 + mergeRequestService.close(currentUser.require(), mr);
131 + return Response.seeOther(detailUri(repo, mr.id)).build();
132 + }
133 +
134 + private URI detailUri(Repository repo, UUID id)
135 + {
136 + return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/merge-requests/" + id);
137 + }
138 +
139 + private boolean isOwner(Repository repo)
140 + {
141 + User user = currentUser.get();
142 + return user != null && user.id.equals(repo.owner.id);
143 + }
144 +
145 + private static UUID parseId(String id)
146 + {
147 + try
148 + {
149 + return UUID.fromString(id);
150 + }
151 + catch (IllegalArgumentException malformed)
152 + {
153 + throw new NotFoundException();
154 + }
155 + }
156 +
157 + private Repository requireReadable(String owner, String name)
158 + {
159 + Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
160 + if (!accessPolicy.canRead(currentUser.get(), repo))
161 + {
162 + // hide existence of private repositories
163 + throw new NotFoundException();
164 + }
165 + return repo;
166 + }
167 +
168 +}
MODIFY src/main/java/de/workaround/web/RepositoryResource.java +8 -2
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index 8d9b23e..1422ec5 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -17,6 +17,7 @@
17 17 import de.workaround.git.GitBrowseService;
18 18 import de.workaround.git.GitRepositoryService;
19 19 import de.workaround.git.IssueService;
20 +import de.workaround.git.MergeRequestService;
20 21 import de.workaround.git.RepositoryPinService;
21 22 import de.workaround.model.Repository;
22 23 import de.workaround.model.User;
@@ -48,7 +49,7 @@
48 49 static native TemplateInstance overview(Repository repo, boolean owner, boolean empty, String defaultBranch,
49 50 List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl, boolean loggedIn,
50 51 boolean pinned, GitBrowseService.CommitInfo latestCommit, String latestCommitAge, int commitCount,
51 - int branchCount, int tagCount, long openIssueCount);
52 + int branchCount, int tagCount, long openIssueCount, long openMrCount);
52 53
53 54 static native TemplateInstance tree(Repository repo, String ref, String path,
54 55 List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs, String activeTab);
@@ -81,6 +82,9 @@
81 82 @Inject
82 83 IssueService issueService;
83 84
85 + @Inject
86 + MergeRequestService mergeRequestService;
87 +
84 88 @ConfigProperty(name = "gitshark.ssh.port")
85 89 int sshPort;
86 90
@@ -111,8 +115,10 @@
111 115 int branchCount = browse.branches(path).size();
112 116 int tagCount = browse.tags(path).size();
113 117 long openIssueCount = issueService.countOpen(repo);
118 + long openMrCount = mergeRequestService.countOpen(repo);
114 119 return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo),
115 - loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount, openIssueCount);
120 + loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount, openIssueCount,
121 + openMrCount);
116 122 }
117 123
118 124 private static String relativeAge(Instant when)
MODIFY src/main/resources/META-INF/resources/shark.css +129 -0
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index df4cda5..fa7013d 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -838,6 +838,135 @@
838 838 font: 500 12.5px/1 var(--mono);
839 839 }
840 840
841 +/* merge requests */
842 +
843 +.badge.status-OPEN {
844 + color: var(--accent-deep);
845 + border-color: transparent;
846 + background: var(--accent-soft);
847 +}
848 +
849 +.badge.status-MERGED {
850 + color: #fff;
851 + border-color: var(--success);
852 + background: var(--success);
853 +}
854 +
855 +.badge.status-CLOSED {
856 + color: var(--danger);
857 + border-color: var(--danger-border);
858 + background: var(--danger-bg);
859 +}
860 +
861 +.mr-branches {
862 + font: 500 12.5px/1 var(--mono);
863 + color: var(--muted);
864 +}
865 +
866 +.mr-branches code {
867 + color: var(--accent-deep);
868 +}
869 +
870 +.mr-branch-picker {
871 + display: flex;
872 + align-items: center;
873 + gap: var(--s2);
874 + flex-wrap: wrap;
875 +}
876 +
877 +.mr-branch-picker select {
878 + font: 500 13px/1 var(--mono);
879 + padding: 6px 8px;
880 + border: 1px solid var(--border);
881 + border-radius: 6px;
882 + background: var(--surface);
883 + color: var(--ink);
884 +}
885 +
886 +.mr-branch-picker .into {
887 + color: var(--muted);
888 +}
889 +
890 +/* diff view */
891 +
892 +.diffstat-summary {
893 + color: var(--muted);
894 + font-size: 13px;
895 +}
896 +
897 +.diffstat .add, .diffstat-summary .add {
898 + color: var(--success-deep);
899 +}
900 +
901 +.diffstat .del, .diffstat-summary .del {
902 + color: var(--danger);
903 +}
904 +
905 +.diff-file {
906 + border: 1px solid var(--border);
907 + border-radius: 8px;
908 + margin: 0 0 var(--s3);
909 + overflow: hidden;
910 +}
911 +
912 +.diff-file-head {
913 + display: flex;
914 + align-items: center;
915 + gap: var(--s2);
916 + padding: var(--s2) var(--s3);
917 + background: var(--border-soft);
918 + border-bottom: 1px solid var(--border);
919 +}
920 +
921 +.diff-file-head .diffstat {
922 + margin-left: auto;
923 + font: 500 12px/1 var(--mono);
924 +}
925 +
926 +.badge.ct-ADD {
927 + color: var(--success-deep);
928 + border-color: transparent;
929 + background: var(--accent-soft);
930 +}
931 +
932 +.badge.ct-DELETE {
933 + color: var(--danger);
934 + border-color: var(--danger-border);
935 + background: var(--danger-bg);
936 +}
937 +
938 +pre.diff {
939 + margin: 0;
940 + padding: 0;
941 + overflow-x: auto;
942 + font: 12.5px/1.5 var(--mono);
943 + background: var(--surface);
944 +}
945 +
946 +pre.diff .dl {
947 + display: block;
948 + padding: 0 var(--s3);
949 + white-space: pre;
950 + color: var(--ink);
951 +}
952 +
953 +pre.diff .dl.add {
954 + background: oklch(0.95 0.05 150);
955 +}
956 +
957 +pre.diff .dl.del {
958 + background: oklch(0.95 0.05 27);
959 +}
960 +
961 +pre.diff .dl.hunk {
962 + color: var(--accent-deep);
963 + background: var(--accent-soft);
964 +}
965 +
966 +pre.diff .dl.meta {
967 + color: var(--faint);
968 +}
969 +
841 970 /* dashboard */
842 971
843 972 .dashboard-section {
ADD src/main/resources/db/migration/V7__merge_requests.sql +19 -0
diff --git a/src/main/resources/db/migration/V7__merge_requests.sql b/src/main/resources/db/migration/V7__merge_requests.sql
new file mode 100644
index 0000000..29dfe25
--- /dev/null
+++ b/src/main/resources/db/migration/V7__merge_requests.sql
@@ -0,0 +1,19 @@
1 +-- Per-repository merge requests: a request to merge one branch into another, reviewed and merged in the UI.
2 +create table merge_requests
3 +(
4 + id uuid primary key,
5 + repository_id uuid not null references repositories (id) on delete cascade,
6 + author_id uuid not null references users (id) on delete cascade,
7 + number integer not null,
8 + title text not null,
9 + description text,
10 + source_branch text not null,
11 + target_branch text not null,
12 + status varchar(32) not null default 'OPEN'
13 + check (status in ('OPEN', 'MERGED', 'CLOSED')),
14 + created_at timestamptz not null default now(),
15 + merged_at timestamptz,
16 + constraint merge_requests_repository_number_unique unique (repository_id, number)
17 +);
18 +
19 +create index merge_requests_repository_idx on merge_requests (repository_id);
ADD src/main/resources/templates/MergeRequestResource/mergeRequest.html +45 -0
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequest.html b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
new file mode 100644
index 0000000..d1a4e95
--- /dev/null
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
@@ -0,0 +1,45 @@
1 +{#include layout}
2 +{#title}{mr.title} – {repo.name}{/title}
3 +<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4 +<p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">&larr; Merge requests</a></p>
5 +<h2>{mr.title} <span class="issue-no">!{mr.number}</span></h2>
6 +<p>
7 + <span class="badge status-{mr.status}">{mr.status.label}</span>
8 + <span class="mr-branches"><code>{mr.sourceBranch}</code> &rarr; <code>{mr.targetBranch}</code></span>
9 + <span class="muted">opened by {mr.author.username}</span>
10 +</p>
11 +{#if mr.description}
12 +<pre class="issue-desc">{mr.description}</pre>
13 +{/if}
14 +{#if owner && mr.status.name() == 'OPEN'}
15 +<div class="issue-actions">
16 + <form method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}/merge">
17 + <button type="submit" class="btn btn-primary">Merge</button>
18 + </form>
19 + <form method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}/close"
20 + onsubmit="return confirm('Close this merge request without merging?')">
21 + <button type="submit" class="btn btn-secondary">Close</button>
22 + </form>
23 +</div>
24 +{/if}
25 +<h3>Changes</h3>
26 +{#if diff == null || diff.files.isEmpty()}
27 +<p class="muted">No changes to show. The source branch has nothing new over the target.</p>
28 +{#else}
29 +<p class="diffstat-summary">
30 + <b>{diff.files.size()}</b> file{#if diff.files.size() != 1}s{/if} changed,
31 + <span class="add">+{diff.additions}</span> <span class="del">-{diff.deletions}</span>
32 +</p>
33 +{#for file in diff.files}
34 +<div class="diff-file">
35 + <div class="diff-file-head">
36 + <span class="badge ct-{file.changeType}">{file.changeType}</span>
37 + <code>{file.path}</code>
38 + <span class="diffstat"><span class="add">+{file.additions}</span> <span class="del">-{file.deletions}</span></span>
39 + </div>
40 + <pre class="diff">{#for line in file.lines}<span class="dl {line.type}">{line.text}</span>
41 +{/for}</pre>
42 +</div>
43 +{/for}
44 +{/if}
45 +{/include}
ADD src/main/resources/templates/MergeRequestResource/mergeRequests.html +43 -0
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequests.html b/src/main/resources/templates/MergeRequestResource/mergeRequests.html
new file mode 100644
index 0000000..ac30838
--- /dev/null
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequests.html
@@ -0,0 +1,43 @@
1 +{#include layout}
2 +{#title}Merge requests – {repo.name}{/title}
3 +<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4 +<div class="issues-head">
5 + <h2>Merge requests</h2>
6 + {#if owner}
7 + <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/new">New merge request</a>
8 + {/if}
9 +</div>
10 +{#if open.isEmpty()}
11 +<p class="muted">No open merge requests.</p>
12 +{#else}
13 +<div class="panel">
14 + {#for mr in open}
15 + <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
16 + <span class="fname">
17 + <span class="badge status-{mr.status}">{mr.status.label}</span>
18 + <span class="n">{mr.title}</span>
19 + <span class="mr-branches"><code>{mr.sourceBranch}</code> &rarr; <code>{mr.targetBranch}</code></span>
20 + <span class="issue-no">!{mr.number}</span>
21 + </span>
22 + </a>
23 + {/for}
24 +</div>
25 +{/if}
26 +{#if closed.size > 0}
27 +<details class="archive">
28 + <summary>Archive <span class="ct">{closed.size}</span></summary>
29 + <div class="panel">
30 + {#for mr in closed}
31 + <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
32 + <span class="fname">
33 + <span class="badge status-{mr.status}">{mr.status.label}</span>
34 + <span class="n">{mr.title}</span>
35 + <span class="mr-branches"><code>{mr.sourceBranch}</code> &rarr; <code>{mr.targetBranch}</code></span>
36 + <span class="issue-no">!{mr.number}</span>
37 + </span>
38 + </a>
39 + {/for}
40 + </div>
41 +</details>
42 +{/if}
43 +{/include}
ADD src/main/resources/templates/MergeRequestResource/newMergeRequest.html +31 -0
diff --git a/src/main/resources/templates/MergeRequestResource/newMergeRequest.html b/src/main/resources/templates/MergeRequestResource/newMergeRequest.html
new file mode 100644
index 0000000..b61d01c
--- /dev/null
+++ b/src/main/resources/templates/MergeRequestResource/newMergeRequest.html
@@ -0,0 +1,31 @@
1 +{#include layout}
2 +{#title}New merge request – {repo.name}{/title}
3 +<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4 +<p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">&larr; Merge requests</a></p>
5 +<h2>New merge request</h2>
6 +<form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests">
7 + <input type="text" name="title" placeholder="Merge request title" required autocomplete="off">
8 + <div class="mr-branch-picker">
9 + <label>Merge
10 + <select name="sourceBranch" required>
11 + {#for branch in branches}
12 + <option value="{branch}">{branch}</option>
13 + {/for}
14 + </select>
15 + </label>
16 + <span class="into">into</span>
17 + <label>
18 + <select name="targetBranch" required>
19 + {#for branch in branches}
20 + <option value="{branch}"{#if branch == defaultBranch} selected{/if}>{branch}</option>
21 + {/for}
22 + </select>
23 + </label>
24 + </div>
25 + <textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
26 + <div class="form-actions">
27 + <button type="submit" class="btn btn-primary">Create merge request</button>
28 + <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests">Cancel</a>
29 + </div>
30 +</form>
31 +{/include}
MODIFY src/main/resources/templates/RepositoryResource/overview.html +1 -0
diff --git a/src/main/resources/templates/RepositoryResource/overview.html b/src/main/resources/templates/RepositoryResource/overview.html
index 8747027..6b52b58 100644
--- a/src/main/resources/templates/RepositoryResource/overview.html
+++ b/src/main/resources/templates/RepositoryResource/overview.html
@@ -32,6 +32,7 @@
32 32 <a href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⑂</span> Branches <span class="ct">{branchCount}</span></a>
33 33 <a href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⬡</span> Tags <span class="ct">{tagCount}</span></a>
34 34 <a href="/repos/{repo.owner.username}/{repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{openIssueCount}</span></a>
35 + <a href="/repos/{repo.owner.username}/{repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{openMrCount}</span></a>
35 36 </nav>
36 37 </aside>
37 38
MODIFY src/main/resources/templates/RepositoryResource/tabs.html +1 -0
diff --git a/src/main/resources/templates/RepositoryResource/tabs.html b/src/main/resources/templates/RepositoryResource/tabs.html
index e1d1b9f..abbbb78 100644
--- a/src/main/resources/templates/RepositoryResource/tabs.html
+++ b/src/main/resources/templates/RepositoryResource/tabs.html
@@ -4,5 +4,6 @@
4 4 <a class="tab{#if activeTab == 'commits'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/commits/{tabRef}">Commits</a>
5 5 <a class="tab{#if activeTab == 'branches'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/branches">Branches</a>
6 6 <a class="tab{#if activeTab == 'issues'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/issues">Issues</a>
7 + <a class="tab{#if activeTab == 'merge-requests'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/merge-requests">Merge requests</a>
7 8 </nav>
8 9 {/if}
ADD src/test/java/de/workaround/git/GitMergeServiceTest.java +170 -0
diff --git a/src/test/java/de/workaround/git/GitMergeServiceTest.java b/src/test/java/de/workaround/git/GitMergeServiceTest.java
new file mode 100644
index 0000000..5a82883
--- /dev/null
+++ b/src/test/java/de/workaround/git/GitMergeServiceTest.java
@@ -0,0 +1,170 @@
1 +package de.workaround.git;
2 +
3 +import java.nio.charset.StandardCharsets;
4 +import java.nio.file.Files;
5 +import java.nio.file.Path;
6 +import java.util.List;
7 +import java.util.Map;
8 +
9 +import org.eclipse.jgit.api.Git;
10 +import org.eclipse.jgit.transport.RefSpec;
11 +import org.junit.jupiter.api.Test;
12 +
13 +import io.quarkus.test.junit.QuarkusTest;
14 +import jakarta.inject.Inject;
15 +
16 +import static org.junit.jupiter.api.Assertions.assertEquals;
17 +import static org.junit.jupiter.api.Assertions.assertFalse;
18 +import static org.junit.jupiter.api.Assertions.assertTrue;
19 +
20 +@QuarkusTest
21 +class GitMergeServiceTest
22 +{
23 + @Inject
24 + GitRepositoryService service;
25 +
26 + @Inject
27 + GitMergeService merge;
28 +
29 + @Inject
30 + de.workaround.model.User.Repo userRepo;
31 +
32 + @Test
33 + void diffListsFilesChangedOnTheSourceBranchRelativeToTheTarget() throws Exception
34 + {
35 + Path bare = seededRepo("gm-diff");
36 + branchWithChange(bare, "feature", "hello.txt", "hello world\n");
37 +
38 + GitMergeService.DiffView diff = merge.diff(bare, "main", "feature").orElseThrow();
39 +
40 + assertEquals(1, diff.files().size());
41 + GitMergeService.FileDiff file = diff.files().get(0);
42 + assertEquals("hello.txt", file.path());
43 + assertTrue(file.additions() >= 1, "the new line should be counted as an addition");
44 + assertTrue(diff.files().get(0).lines().stream().anyMatch(l -> l.text().contains("hello world")));
45 + }
46 +
47 + @Test
48 + void mergeCreatesAMergeCommitAndAdvancesTheTargetBranch() throws Exception
49 + {
50 + Path bare = seededRepo("gm-merge");
51 + branchWithChange(bare, "feature", "feature.txt", "new feature\n");
52 +
53 + GitMergeService.MergeResult result = merge.merge(bare, "main", "feature", "Alice", "alice@example.com",
54 + "Merge feature into main");
55 +
56 + assertEquals(GitMergeService.MergeResult.MERGED, result);
57 + // the merged file is now reachable from main
58 + assertTrue(fileExistsOnBranch(bare, "main", "feature.txt"),
59 + "after merging, the feature file must be present on main");
60 + }
61 +
62 + @Test
63 + void mergeReportsConflictWhenBothBranchesChangeTheSameLines() throws Exception
64 + {
65 + Path bare = seededRepo("gm-conflict");
66 + // diverge: change base.txt on main and differently on feature
67 + branchWithChange(bare, "feature", "base.txt", "feature side\n");
68 + commitOnBranch(bare, "main", "base.txt", "main side\n");
69 +
70 + GitMergeService.MergeResult result = merge.merge(bare, "main", "feature", "Alice", "alice@example.com",
71 + "Merge");
72 +
73 + assertEquals(GitMergeService.MergeResult.CONFLICT, result);
74 + }
75 +
76 + @Test
77 + void mergeReportsUpToDateWhenSourceIsAlreadyContainedInTarget() throws Exception
78 + {
79 + Path bare = seededRepo("gm-uptodate");
80 + // feature points at the same commit as main -> nothing to merge
81 + createBranchAt(bare, "feature", "main");
82 +
83 + GitMergeService.MergeResult result = merge.merge(bare, "main", "feature", "Alice", "alice@example.com",
84 + "Merge");
85 +
86 + assertEquals(GitMergeService.MergeResult.UP_TO_DATE, result);
87 + }
88 +
89 + @Test
90 + void branchExistenceIsReported() throws Exception
91 + {
92 + Path bare = seededRepo("gm-exists");
93 + assertTrue(merge.branchExists(bare, "main"));
94 + assertFalse(merge.branchExists(bare, "does-not-exist"));
95 + }
96 +
97 + private Path seededRepo(String name) throws Exception
98 + {
99 + de.workaround.model.User owner = persistUser(name);
100 + de.workaround.model.Repository repo = service.create(owner, name,
101 + de.workaround.model.Repository.Visibility.PUBLIC, null);
102 + Path bare = service.repositoryPath(repo);
103 + GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)));
104 + return bare;
105 + }
106 +
107 + /** Branches off main, adds/updates one file, pushes the branch. Leaves main untouched. */
108 + private static void branchWithChange(Path bare, String branch, String file, String content) throws Exception
109 + {
110 + Path work = Files.createTempDirectory("mergework");
111 + try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setDirectory(work.toFile()).call())
112 + {
113 + git.checkout().setCreateBranch(true).setName(branch).call();
114 + Files.writeString(work.resolve(file), content);
115 + git.add().addFilepattern(".").call();
116 + git.commit().setMessage("change on " + branch).setSign(false)
117 + .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
118 + git.push().setRefSpecs(new RefSpec(branch + ":refs/heads/" + branch)).call();
119 + }
120 + }
121 +
122 + private static void commitOnBranch(Path bare, String branch, String file, String content) throws Exception
123 + {
124 + Path work = Files.createTempDirectory("mergework");
125 + try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setBranch(branch)
126 + .setDirectory(work.toFile()).call())
127 + {
128 + Files.writeString(work.resolve(file), content);
129 + git.add().addFilepattern(".").call();
130 + git.commit().setMessage("change on " + branch).setSign(false)
131 + .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
132 + git.push().setRefSpecs(new RefSpec(branch + ":refs/heads/" + branch)).call();
133 + }
134 + }
135 +
136 + private static void createBranchAt(Path bare, String branch, String at) throws Exception
137 + {
138 + Path work = Files.createTempDirectory("mergework");
139 + try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setBranch(at)
140 + .setDirectory(work.toFile()).call())
141 + {
142 + git.push().setRefSpecs(new RefSpec(at + ":refs/heads/" + branch)).call();
143 + }
144 + }
145 +
146 + private static boolean fileExistsOnBranch(Path bare, String branch, String file) throws Exception
147 + {
148 + Path work = Files.createTempDirectory("mergecheck");
149 + try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setBranch(branch)
150 + .setDirectory(work.toFile()).call())
151 + {
152 + return Files.exists(work.resolve(file));
153 + }
154 + }
155 +
156 + @jakarta.transaction.Transactional
157 + de.workaround.model.User persistUser(String name)
158 + {
159 + de.workaround.model.User existing = userRepo.findByOidcSubOptional(name).orElse(null);
160 + if (existing != null)
161 + {
162 + return existing;
163 + }
164 + de.workaround.model.User user = new de.workaround.model.User();
165 + user.oidcSub = name;
166 + user.username = name;
167 + user.persist();
168 + return user;
169 + }
170 +}
ADD src/test/java/de/workaround/git/MergeRequestServiceTest.java +198 -0
diff --git a/src/test/java/de/workaround/git/MergeRequestServiceTest.java b/src/test/java/de/workaround/git/MergeRequestServiceTest.java
new file mode 100644
index 0000000..b700640
--- /dev/null
+++ b/src/test/java/de/workaround/git/MergeRequestServiceTest.java
@@ -0,0 +1,198 @@
1 +package de.workaround.git;
2 +
3 +import java.nio.charset.StandardCharsets;
4 +import java.nio.file.Files;
5 +import java.nio.file.Path;
6 +import java.util.Map;
7 +import java.util.UUID;
8 +
9 +import org.eclipse.jgit.api.Git;
10 +import org.eclipse.jgit.transport.RefSpec;
11 +import org.junit.jupiter.api.Test;
12 +
13 +import de.workaround.model.MergeRequest;
14 +import de.workaround.model.Repository;
15 +import de.workaround.model.User;
16 +import io.quarkus.test.junit.QuarkusTest;
17 +import jakarta.inject.Inject;
18 +import jakarta.transaction.Transactional;
19 +
20 +import static org.junit.jupiter.api.Assertions.assertEquals;
21 +import static org.junit.jupiter.api.Assertions.assertNotNull;
22 +import static org.junit.jupiter.api.Assertions.assertNull;
23 +import static org.junit.jupiter.api.Assertions.assertThrows;
24 +import static org.junit.jupiter.api.Assertions.assertTrue;
25 +
26 +@QuarkusTest
27 +class MergeRequestServiceTest
28 +{
29 + @Inject
30 + GitRepositoryService service;
31 +
32 + @Inject
33 + MergeRequestService mergeRequests;
34 +
35 + @Inject
36 + MergeRequest.Repo repo;
37 +
38 + @Inject
39 + User.Repo userRepo;
40 +
41 + @Test
42 + void createStoresAnOpenMergeRequestWithSourceAndTargetBranch() throws Exception
43 + {
44 + User owner = persistUser("mr-alice");
45 + Repository repository = seed(owner, "mra");
46 +
47 + MergeRequest mr = mergeRequests.create(owner, repository, "Add feature", "does things", "feature", "main");
48 +
49 + assertEquals("Add feature", mr.title);
50 + assertEquals("does things", mr.description);
51 + assertEquals("feature", mr.sourceBranch);
52 + assertEquals("main", mr.targetBranch);
53 + assertEquals(MergeRequest.Status.OPEN, mr.status);
54 + assertEquals(owner.id, mr.author.id);
55 + assertEquals(1, mr.number);
56 + }
57 +
58 + @Test
59 + void createRejectsBlankTitle() throws Exception
60 + {
61 + User owner = persistUser("mr-bob");
62 + Repository repository = seed(owner, "mrb");
63 + assertThrows(InvalidMergeRequestException.class,
64 + () -> mergeRequests.create(owner, repository, " ", null, "feature", "main"));
65 + }
66 +
67 + @Test
68 + void createRejectsIdenticalSourceAndTargetBranch() throws Exception
69 + {
70 + User owner = persistUser("mr-cara");
71 + Repository repository = seed(owner, "mrc");
72 + assertThrows(InvalidMergeRequestException.class,
73 + () -> mergeRequests.create(owner, repository, "t", null, "main", "main"));
74 + }
75 +
76 + @Test
77 + void createRejectsUnknownBranch() throws Exception
78 + {
79 + User owner = persistUser("mr-dan");
80 + Repository repository = seed(owner, "mrd");
81 + assertThrows(InvalidMergeRequestException.class,
82 + () -> mergeRequests.create(owner, repository, "t", null, "ghost", "main"));
83 + }
84 +
85 + @Test
86 + void numbersAreSequentialPerRepository() throws Exception
87 + {
88 + User owner = persistUser("mr-erin");
89 + Repository a = seed(owner, "mre-a");
90 + Repository b = seed(owner, "mre-b");
91 + assertEquals(1, mergeRequests.create(owner, a, "1", null, "feature", "main").number);
92 + assertEquals(2, mergeRequests.create(owner, a, "2", null, "feature", "main").number);
93 + assertEquals(1, mergeRequests.create(owner, b, "1", null, "feature", "main").number,
94 + "numbering restarts per repository");
95 + }
96 +
97 + @Test
98 + void mergingMovesTheMergeRequestToMergedAndAdvancesTheTarget() throws Exception
99 + {
100 + User owner = persistUser("mr-finn");
101 + Repository repository = seed(owner, "mrf");
102 + MergeRequest mr = mergeRequests.create(owner, repository, "Merge me", null, "feature", "main");
103 +
104 + GitMergeService.MergeResult result = mergeRequests.merge(owner, mr);
105 +
106 + assertEquals(GitMergeService.MergeResult.MERGED, result);
107 + MergeRequest reloaded = repo.findById(mr.id);
108 + assertEquals(MergeRequest.Status.MERGED, reloaded.status);
109 + assertNotNull(reloaded.mergedAt);
110 + }
111 +
112 + @Test
113 + void closingMovesTheMergeRequestToClosedWithoutMerging() throws Exception
114 + {
115 + User owner = persistUser("mr-gwen");
116 + Repository repository = seed(owner, "mrg");
117 + MergeRequest mr = mergeRequests.create(owner, repository, "Close me", null, "feature", "main");
118 +
119 + mergeRequests.close(owner, mr);
120 +
121 + assertEquals(MergeRequest.Status.CLOSED, repo.findById(mr.id).status);
122 + }
123 +
124 + @Test
125 + void nonOwnerCannotCreateMergeOrClose() throws Exception
126 + {
127 + User owner = persistUser("mr-hugo");
128 + User stranger = persistUser("mr-ivan");
129 + Repository repository = seed(owner, "mrh");
130 + MergeRequest mr = mergeRequests.create(owner, repository, "Owned", null, "feature", "main");
131 +
132 + assertThrows(ForbiddenOperationException.class,
133 + () -> mergeRequests.create(stranger, repository, "sneaky", null, "feature", "main"));
134 + assertThrows(ForbiddenOperationException.class, () -> mergeRequests.merge(stranger, mr));
135 + assertThrows(ForbiddenOperationException.class, () -> mergeRequests.close(stranger, mr));
136 + }
137 +
138 + @Test
139 + void countOpenExcludesMergedAndClosed() throws Exception
140 + {
141 + User owner = persistUser("mr-jane");
142 + Repository repository = seed(owner, "mrj");
143 + mergeRequests.create(owner, repository, "still open", null, "feature", "main");
144 + MergeRequest merged = mergeRequests.create(owner, repository, "merged", null, "feature", "main");
145 + MergeRequest closed = mergeRequests.create(owner, repository, "closed", null, "feature", "main");
146 + mergeRequests.merge(owner, merged);
147 + mergeRequests.close(owner, closed);
148 +
149 + assertEquals(1L, mergeRequests.countOpen(repository));
150 + }
151 +
152 + @Test
153 + void deletingARepositoryRemovesItsMergeRequests() throws Exception
154 + {
155 + User owner = persistUser("mr-kim");
156 + Repository repository = seed(owner, "mrk");
157 + MergeRequest mr = mergeRequests.create(owner, repository, "Doomed", null, "feature", "main");
158 + UUID mrId = mr.id;
159 +
160 + service.delete(owner, repository);
161 +
162 + assertNull(repo.findById(mrId), "merge request must be cascade-deleted with its repository");
163 + }
164 +
165 + /** Creates a repo with a base commit on main plus a divergent 'feature' branch (adds feature.txt). */
166 + private Repository seed(User owner, String name) throws Exception
167 + {
168 + Repository repository = service.create(owner, name, Repository.Visibility.PUBLIC, null);
169 + Path bare = service.repositoryPath(repository);
170 + GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)));
171 + Path work = Files.createTempDirectory("mrseed");
172 + try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setDirectory(work.toFile()).call())
173 + {
174 + git.checkout().setCreateBranch(true).setName("feature").call();
175 + Files.writeString(work.resolve("feature.txt"), "feature\n");
176 + git.add().addFilepattern(".").call();
177 + git.commit().setMessage("feature work").setSign(false)
178 + .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
179 + git.push().setRefSpecs(new RefSpec("feature:refs/heads/feature")).call();
180 + }
181 + return repository;
182 + }
183 +
184 + @Transactional
185 + User persistUser(String name)
186 + {
187 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
188 + if (existing != null)
189 + {
190 + return existing;
191 + }
192 + User user = new User();
193 + user.oidcSub = name;
194 + user.username = name;
195 + user.persist();
196 + return user;
197 + }
198 +}
ADD src/test/java/de/workaround/web/MergeRequestUiTest.java +186 -0
diff --git a/src/test/java/de/workaround/web/MergeRequestUiTest.java b/src/test/java/de/workaround/web/MergeRequestUiTest.java
new file mode 100644
index 0000000..3e585c1
--- /dev/null
+++ b/src/test/java/de/workaround/web/MergeRequestUiTest.java
@@ -0,0 +1,186 @@
1 +package de.workaround.web;
2 +
3 +import java.nio.charset.StandardCharsets;
4 +import java.nio.file.Files;
5 +import java.nio.file.Path;
6 +import java.util.Map;
7 +import java.util.UUID;
8 +
9 +import org.eclipse.jgit.api.Git;
10 +import org.eclipse.jgit.transport.RefSpec;
11 +import org.junit.jupiter.api.Test;
12 +
13 +import de.workaround.git.GitRepositoryService;
14 +import de.workaround.git.GitTestSeeder;
15 +import de.workaround.model.Repository;
16 +import de.workaround.model.User;
17 +import io.quarkus.test.junit.QuarkusTest;
18 +import io.quarkus.test.security.TestSecurity;
19 +import jakarta.inject.Inject;
20 +import jakarta.transaction.Transactional;
21 +
22 +import static io.restassured.RestAssured.given;
23 +import static org.hamcrest.CoreMatchers.containsString;
24 +import static org.hamcrest.CoreMatchers.not;
25 +
26 +@QuarkusTest
27 +class MergeRequestUiTest
28 +{
29 + @Inject
30 + GitRepositoryService service;
31 +
32 + @Inject
33 + User.Repo userRepo;
34 +
35 + @Test
36 + @TestSecurity(user = "mru-owner")
37 + void ownerCanOpenViewDiffAndMergeAMergeRequest()
38 + {
39 + User owner = persistUser("mru-owner");
40 + Repository repo = seed(owner, "board");
41 + String base = "/repos/" + owner.username + "/board/merge-requests";
42 +
43 + // the new-MR form lets the owner pick source and target branches
44 + given().when().get(base + "/new")
45 + .then().statusCode(200)
46 + .body(containsString("<form"))
47 + .body(containsString("name=\"sourceBranch\""))
48 + .body(containsString("name=\"targetBranch\""))
49 + .body(containsString("feature"));
50 +
51 + // create it
52 + String location = given().redirects().follow(false)
53 + .contentType("application/x-www-form-urlencoded")
54 + .formParam("title", "Ship the feature").formParam("description", "adds feature.txt")
55 + .formParam("sourceBranch", "feature").formParam("targetBranch", "main")
56 + .when().post(base)
57 + .then().statusCode(303)
58 + .extract().header("Location");
59 +
60 + // list shows it as open
61 + given().when().get(base)
62 + .then().statusCode(200)
63 + .body(containsString("Ship the feature"))
64 + .body(containsString("Open"));
65 +
66 + // detail shows the diff of the source branch and a merge control
67 + given().when().get(location)
68 + .then().statusCode(200)
69 + .body(containsString("feature.txt"))
70 + .body(containsString("feature"))
71 + .body(containsString("Merge"));
72 +
73 + // merge it
74 + given().redirects().follow(false)
75 + .contentType("application/x-www-form-urlencoded")
76 + .when().post(location + "/merge")
77 + .then().statusCode(303);
78 +
79 + given().when().get(location).then().statusCode(200).body(containsString("Merged"));
80 + }
81 +
82 + @Test
83 + @TestSecurity(user = "mru-closer")
84 + void ownerCanCloseAMergeRequestWithoutMerging()
85 + {
86 + User owner = persistUser("mru-closer");
87 + Repository repo = seed(owner, "closeboard");
88 + String base = "/repos/" + owner.username + "/closeboard/merge-requests";
89 +
90 + String location = given().redirects().follow(false)
91 + .contentType("application/x-www-form-urlencoded")
92 + .formParam("title", "Never mind").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
93 + .when().post(base)
94 + .then().statusCode(303)
95 + .extract().header("Location");
96 +
97 + given().redirects().follow(false)
98 + .contentType("application/x-www-form-urlencoded")
99 + .when().post(location + "/close")
100 + .then().statusCode(303);
101 +
102 + given().when().get(location).then().statusCode(200).body(containsString("Closed"));
103 + }
104 +
105 + @Test
106 + void anonymousCannotCreateMergeRequests()
107 + {
108 + User owner = persistUser("mru-anon-" + UUID.randomUUID().toString().substring(0, 8));
109 + seed(owner, "board");
110 +
111 + given().contentType("application/x-www-form-urlencoded")
112 + .formParam("title", "Sneaky").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
113 + .when().post("/repos/" + owner.username + "/board/merge-requests")
114 + .then().statusCode(403);
115 + }
116 +
117 + @Test
118 + @TestSecurity(user = "mru-stranger")
119 + void privateRepositoryMergeRequestsAreHiddenFromStrangers()
120 + {
121 + persistUser("mru-stranger");
122 + User owner = persistUser("mru-owner2-" + UUID.randomUUID().toString().substring(0, 8));
123 + Repository repo = service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
124 +
125 + given().when().get("/repos/" + owner.username + "/secret/merge-requests")
126 + .then().statusCode(404);
127 + }
128 +
129 + @Test
130 + @TestSecurity(user = "mru-count")
131 + void repoOverviewShowsOpenMergeRequestCount()
132 + {
133 + User owner = persistUser("mru-count");
134 + Repository repo = seed(owner, "counts");
135 + String base = "/repos/" + owner.username + "/counts/merge-requests";
136 + given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
137 + .formParam("title", "one").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
138 + .when().post(base).then().statusCode(303);
139 +
140 + given().when().get("/repos/" + owner.username + "/counts")
141 + .then().statusCode(200)
142 + .body(containsString("Merge requests"))
143 + .body(containsString("<span class=\"ct\">1</span>"));
144 + }
145 +
146 + /** Repo with a base commit on main and a divergent 'feature' branch adding feature.txt. */
147 + private Repository seed(User owner, String name)
148 + {
149 + try
150 + {
151 + Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
152 + Path bare = service.repositoryPath(repo);
153 + GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)));
154 + Path work = Files.createTempDirectory("mruseed");
155 + try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setDirectory(work.toFile()).call())
156 + {
157 + git.checkout().setCreateBranch(true).setName("feature").call();
158 + Files.writeString(work.resolve("feature.txt"), "feature\n");
159 + git.add().addFilepattern(".").call();
160 + git.commit().setMessage("feature work").setSign(false)
161 + .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
162 + git.push().setRefSpecs(new RefSpec("feature:refs/heads/feature")).call();
163 + }
164 + return repo;
165 + }
166 + catch (Exception e)
167 + {
168 + throw new RuntimeException(e);
169 + }
170 + }
171 +
172 + @Transactional
173 + User persistUser(String name)
174 + {
175 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
176 + if (existing != null)
177 + {
178 + return existing;
179 + }
180 + User user = new User();
181 + user.oidcSub = name;
182 + user.username = name;
183 + user.persist();
184 + return user;
185 + }
186 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog