gitshark

Clone repository

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

← Commits

✨ (issues): Add per-repository issue tracker with commit-closing

fa18d2bcf396512b79f4f8c50964776fc6461aba · Phillip Souza Furtner · 2026-07-07T20:41:50Z

Changes

23 files changed, +1378 -14

MODIFY README.md +4 -1
diff --git a/README.md b/README.md
index 132af78..a8debad 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,10 @@
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 tab navigation that preserves the selected ref. 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 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 +- 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 +- 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 +- 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
18 21 - 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)
19 22 - Single access policy on all paths: owner read/write, public world-readable, private owner-only
20 23 - **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
ADD src/main/java/de/workaround/git/InvalidIssueException.java +10 -0
diff --git a/src/main/java/de/workaround/git/InvalidIssueException.java b/src/main/java/de/workaround/git/InvalidIssueException.java
new file mode 100644
index 0000000..ab479d6
--- /dev/null
+++ b/src/main/java/de/workaround/git/InvalidIssueException.java
@@ -0,0 +1,10 @@
1 +package de.workaround.git;
2 +
3 +/** Thrown when an issue is rejected for invalid input, e.g. a blank title. */
4 +public class InvalidIssueException extends RuntimeException
5 +{
6 + public InvalidIssueException(String message)
7 + {
8 + super(message);
9 + }
10 +}
ADD src/main/java/de/workaround/git/IssueCommitCloser.java +147 -0
diff --git a/src/main/java/de/workaround/git/IssueCommitCloser.java b/src/main/java/de/workaround/git/IssueCommitCloser.java
new file mode 100644
index 0000000..6dc3ae3
--- /dev/null
+++ b/src/main/java/de/workaround/git/IssueCommitCloser.java
@@ -0,0 +1,147 @@
1 +package de.workaround.git;
2 +
3 +import java.util.Collection;
4 +import java.util.LinkedHashSet;
5 +import java.util.Set;
6 +import java.util.UUID;
7 +import java.util.regex.Matcher;
8 +import java.util.regex.Pattern;
9 +
10 +import org.eclipse.jgit.lib.ObjectId;
11 +import org.eclipse.jgit.revwalk.RevCommit;
12 +import org.eclipse.jgit.revwalk.RevWalk;
13 +import org.eclipse.jgit.transport.ReceiveCommand;
14 +
15 +import de.workaround.model.Repository;
16 +import io.quarkus.arc.Arc;
17 +import jakarta.enterprise.context.ApplicationScoped;
18 +import jakarta.inject.Inject;
19 +import org.jboss.logging.Logger;
20 +
21 +/**
22 + * Closes issues referenced by pushed commit messages, GitHub-style: a message containing a closing
23 + * keyword and an issue number (e.g. {@code fixes #12}, {@code closes #3}) moves that issue to DONE.
24 + * Invoked from the transports' post-receive hooks on a Git worker thread with no CDI request context,
25 + * so it activates one (like {@code FederationPushService}). Never throws into the Git path.
26 + */
27 +@ApplicationScoped
28 +public class IssueCommitCloser
29 +{
30 + private static final Logger LOG = Logger.getLogger(IssueCommitCloser.class);
31 +
32 + private static final int MAX_COMMITS = 100;
33 +
34 + // close/closes/closed, fix/fixes/fixed, resolve/resolves/resolved — followed by #<number>
35 + private static final Pattern CLOSING = Pattern.compile(
36 + "(?i)\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#(\\d+)");
37 +
38 + @Inject
39 + GitRepositoryService repositories;
40 +
41 + @Inject
42 + IssueService issues;
43 +
44 + /** Entry point from the transports' post-receive hooks. */
45 + public void onPush(String ownerName, String repoName, UUID pusherUserId,
46 + org.eclipse.jgit.lib.Repository db, Collection<ReceiveCommand> commands)
47 + {
48 + var requestContext = Arc.container().requestContext();
49 + boolean activated = !requestContext.isActive();
50 + if (activated)
51 + {
52 + requestContext.activate();
53 + }
54 + try
55 + {
56 + close(ownerName, repoName, db, commands);
57 + }
58 + catch (RuntimeException e)
59 + {
60 + LOG.warnf(e, "Failed to close issues from pushed commits for %s/%s", ownerName, repoName);
61 + }
62 + finally
63 + {
64 + if (activated)
65 + {
66 + requestContext.terminate();
67 + }
68 + }
69 + }
70 +
71 + private void close(String ownerName, String repoName, org.eclipse.jgit.lib.Repository db,
72 + Collection<ReceiveCommand> commands)
73 + {
74 + Repository repo = repositories.find(ownerName, repoName).orElse(null);
75 + if (repo == null)
76 + {
77 + return;
78 + }
79 + Set<Integer> referenced = new LinkedHashSet<>();
80 + for (ReceiveCommand command : commands)
81 + {
82 + if (command.getResult() != ReceiveCommand.Result.OK
83 + || !command.getRefName().startsWith("refs/heads/")
84 + || command.getType() == ReceiveCommand.Type.DELETE)
85 + {
86 + continue;
87 + }
88 + referenced.addAll(referencedIssues(db, command.getOldId(), command.getNewId()));
89 + }
90 + for (int number : referenced)
91 + {
92 + issues.markDoneByNumber(repo, number);
93 + }
94 + }
95 +
96 + private static Set<Integer> referencedIssues(org.eclipse.jgit.lib.Repository db, ObjectId oldId, ObjectId newId)
97 + {
98 + Set<Integer> numbers = new LinkedHashSet<>();
99 + try (RevWalk walk = new RevWalk(db))
100 + {
101 + walk.markStart(walk.parseCommit(newId));
102 + if (oldId != null && !oldId.equals(ObjectId.zeroId()))
103 + {
104 + walk.markUninteresting(walk.parseCommit(oldId));
105 + }
106 + int count = 0;
107 + for (RevCommit commit : walk)
108 + {
109 + numbers.addAll(parseClosedIssues(commit.getFullMessage()));
110 + if (++count >= MAX_COMMITS)
111 + {
112 + break;
113 + }
114 + }
115 + }
116 + catch (Exception e)
117 + {
118 + // best-effort: enumerating new commits failed, so nothing is closed from this push
119 + LOG.debugf(e, "Could not enumerate pushed commits between %s and %s", oldId, newId);
120 + }
121 + return numbers;
122 + }
123 +
124 + /** Extracts the issue numbers referenced with a closing keyword in a commit message. */
125 + static Set<Integer> parseClosedIssues(String message)
126 + {
127 + Set<Integer> numbers = new LinkedHashSet<>();
128 + if (message == null)
129 + {
130 + return numbers;
131 + }
132 + Matcher matcher = CLOSING.matcher(message);
133 + while (matcher.find())
134 + {
135 + try
136 + {
137 + numbers.add(Integer.parseInt(matcher.group(1)));
138 + }
139 + catch (NumberFormatException overflow)
140 + {
141 + // a number larger than int can't match any issue; ignore it
142 + }
143 + }
144 + return numbers;
145 + }
146 +
147 +}
ADD src/main/java/de/workaround/git/IssueService.java +104 -0
diff --git a/src/main/java/de/workaround/git/IssueService.java b/src/main/java/de/workaround/git/IssueService.java
new file mode 100644
index 0000000..3ff5fa7
--- /dev/null
+++ b/src/main/java/de/workaround/git/IssueService.java
@@ -0,0 +1,104 @@
1 +package de.workaround.git;
2 +
3 +import java.util.List;
4 +import java.util.Optional;
5 +import java.util.UUID;
6 +
7 +import de.workaround.model.Issue;
8 +import de.workaround.model.Repository;
9 +import de.workaround.model.User;
10 +import jakarta.enterprise.context.ApplicationScoped;
11 +import jakarta.inject.Inject;
12 +import jakarta.transaction.Transactional;
13 +
14 +/**
15 + * Manages per-repository issues. Reading follows the repository's read-visibility rule (enforced by
16 + * callers); creating, transitioning and deleting issues require write access, i.e. repository ownership.
17 + */
18 +@ApplicationScoped
19 +public class IssueService
20 +{
21 + @Inject
22 + Issue.Repo issues;
23 +
24 + @Inject
25 + AccessPolicy accessPolicy;
26 +
27 + @Transactional
28 + public Issue create(User actor, Repository repository, String title, String description)
29 + {
30 + requireWrite(actor, repository);
31 + String trimmedTitle = title == null ? "" : title.strip();
32 + if (trimmedTitle.isEmpty())
33 + {
34 + throw new InvalidIssueException("Issue title must not be empty");
35 + }
36 + Issue issue = new Issue();
37 + issue.repository = repository;
38 + issue.author = actor;
39 + issue.number = issues.maxNumber(repository) + 1;
40 + issue.title = trimmedTitle;
41 + issue.description = description == null || description.isBlank() ? null : description.strip();
42 + issue.status = Issue.Status.PLANNED;
43 + issue.persist();
44 + return issue;
45 + }
46 +
47 + public List<Issue> list(Repository repository)
48 + {
49 + return issues.findByRepository(repository);
50 + }
51 +
52 + /** Number of open issues (PLANNED or IN_DEVELOPMENT) in the repository; DONE issues are excluded. */
53 + public long countOpen(Repository repository)
54 + {
55 + return issues.countOpen(repository);
56 + }
57 +
58 + public Optional<Issue> find(Repository repository, UUID id)
59 + {
60 + return issues.findByRepositoryAndId(repository, id);
61 + }
62 +
63 + @Transactional
64 + public void updateStatus(User actor, Issue issue, Issue.Status status)
65 + {
66 + requireWrite(actor, issue.repository);
67 + // re-attach: the issue may have been loaded in a previous request/transaction. It may also have
68 + // been deleted concurrently since then, so guard against a missing row (findById returns null).
69 + Issue managed = issues.findById(issue.id);
70 + if (managed != null)
71 + {
72 + managed.status = status;
73 + }
74 + }
75 +
76 + @Transactional
77 + public void delete(User actor, Issue issue)
78 + {
79 + requireWrite(actor, issue.repository);
80 + issues.deleteById(issue.id);
81 + }
82 +
83 + /**
84 + * Closes (moves to DONE) the issue with the given number in the repository, if it exists and is still open.
85 + * Called from the post-receive path when a pushed commit references the issue with a closing keyword; the push
86 + * itself was already write-authorized by the transport, so no per-actor check is repeated here. Idempotent.
87 + */
88 + @Transactional
89 + public void markDoneByNumber(Repository repository, int number)
90 + {
91 + issues.findByRepositoryAndNumber(repository, number)
92 + .filter(issue -> issue.status != Issue.Status.DONE)
93 + .ifPresent(issue -> issue.status = Issue.Status.DONE);
94 + }
95 +
96 + private void requireWrite(User actor, Repository repository)
97 + {
98 + if (!accessPolicy.canWrite(actor, repository))
99 + {
100 + throw new ForbiddenOperationException("Only the repository owner can manage issues");
101 + }
102 + }
103 +
104 +}
MODIFY src/main/java/de/workaround/http/GitHttpServlet.java +8 -2
diff --git a/src/main/java/de/workaround/http/GitHttpServlet.java b/src/main/java/de/workaround/http/GitHttpServlet.java
index ebf0095..2230e0d 100644
--- a/src/main/java/de/workaround/http/GitHttpServlet.java
+++ b/src/main/java/de/workaround/http/GitHttpServlet.java
@@ -12,6 +12,7 @@
12 12 import de.workaround.federation.FederationPushService;
13 13 import de.workaround.git.AccessPolicy;
14 14 import de.workaround.git.GitRepositoryService;
15 +import de.workaround.git.IssueCommitCloser;
15 16 import de.workaround.model.Repository;
16 17 import de.workaround.model.User;
17 18 import jakarta.inject.Inject;
@@ -42,6 +43,9 @@
42 43 @Inject
43 44 FederationPushService pushService;
44 45
46 + @Inject
47 + IssueCommitCloser issueCloser;
48 +
45 49 @Override
46 50 public void init(ServletConfig config) throws ServletException
47 51 {
@@ -105,8 +109,10 @@
105 109 String ownerName = repository.owner.username;
106 110 String repoName = repository.name;
107 111 java.util.UUID pusherId = user.id;
108 - receivePack.setPostReceiveHook((rp, commands) ->
109 - pushService.onPush(ownerName, repoName, pusherId, rp.getRepository(), commands));
112 + receivePack.setPostReceiveHook((rp, commands) -> {
113 + pushService.onPush(ownerName, repoName, pusherId, rp.getRepository(), commands);
114 + issueCloser.onPush(ownerName, repoName, pusherId, rp.getRepository(), commands);
115 + });
110 116 return receivePack;
111 117 }
112 118
ADD src/main/java/de/workaround/model/Issue.java +86 -0
diff --git a/src/main/java/de/workaround/model/Issue.java b/src/main/java/de/workaround/model/Issue.java
new file mode 100644
index 0000000..9d6d05f
--- /dev/null
+++ b/src/main/java/de/workaround/model/Issue.java
@@ -0,0 +1,86 @@
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 work item tracked against a single repository. Issues carry a free-text title and description
24 + * and move through a small fixed lifecycle ({@link Status}). They are owned by the repository they
25 + * belong to and are removed with it (DB-level ON DELETE CASCADE).
26 + */
27 +@Entity
28 +@Table(name = "issues")
29 +public class Issue 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 + @Enumerated(EnumType.STRING)
49 + public Status status = Status.PLANNED;
50 +
51 + public Instant createdAt = Instant.now();
52 +
53 + public enum Status
54 + {
55 + PLANNED("Planned"),
56 + IN_DEVELOPMENT("In development"),
57 + DONE("Done");
58 +
59 + /** Human-readable label for the UI; the enum name is the stable value used in forms and the DB. */
60 + public final String label;
61 +
62 + Status(String label)
63 + {
64 + this.label = label;
65 + }
66 + }
67 +
68 + public interface Repo extends PanacheRepository.Managed<Issue, UUID>
69 + {
70 + @HQL("select i from Issue i join fetch i.author where i.repository = :repository order by i.createdAt desc")
71 + List<Issue> findByRepository(Repository repository);
72 +
73 + @Find
74 + Optional<Issue> findByRepositoryAndId(Repository repository, UUID id);
75 +
76 + @Find
77 + Optional<Issue> findByRepositoryAndNumber(Repository repository, int number);
78 +
79 + @HQL("select count(i) from Issue i where i.repository = :repository and i.status in (PLANNED, IN_DEVELOPMENT)")
80 + long countOpen(Repository repository);
81 +
82 + @HQL("select coalesce(max(i.number), 0) from Issue i where i.repository = :repository")
83 + int maxNumber(Repository repository);
84 + }
85 +
86 +}
MODIFY src/main/java/de/workaround/ssh/GitSshCommandFactory.java +7 -2
diff --git a/src/main/java/de/workaround/ssh/GitSshCommandFactory.java b/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
index fba28f6..3ac44c3 100644
--- a/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
+++ b/src/main/java/de/workaround/ssh/GitSshCommandFactory.java
@@ -33,6 +33,9 @@
33 33 @Inject
34 34 de.workaround.federation.FederationPushService pushService;
35 35
36 + @Inject
37 + de.workaround.git.IssueCommitCloser issueCloser;
38 +
36 39 @Override
37 40 public Command createCommand(ChannelSession channel, String command)
38 41 {
@@ -142,8 +145,10 @@
142 145 {
143 146 String ownerName = parts[0];
144 147 String repoName = parts[1];
145 - receivePack.setPostReceiveHook((rp, commands) ->
146 - pushService.onPush(ownerName, repoName, userId, rp.getRepository(), commands));
148 + receivePack.setPostReceiveHook((rp, commands) -> {
149 + pushService.onPush(ownerName, repoName, userId, rp.getRepository(), commands);
150 + issueCloser.onPush(ownerName, repoName, userId, rp.getRepository(), commands);
151 + });
147 152 }
148 153 receivePack.receive(in, out, err);
149 154 }
ADD src/main/java/de/workaround/web/InvalidIssueExceptionMapper.java +25 -0
diff --git a/src/main/java/de/workaround/web/InvalidIssueExceptionMapper.java b/src/main/java/de/workaround/web/InvalidIssueExceptionMapper.java
new file mode 100644
index 0000000..b5bedda
--- /dev/null
+++ b/src/main/java/de/workaround/web/InvalidIssueExceptionMapper.java
@@ -0,0 +1,25 @@
1 +package de.workaround.web;
2 +
3 +import de.workaround.git.InvalidIssueException;
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 InvalidIssueException} to HTTP 400, so invalid issue input (e.g. a blank
11 + * title) surfaces as a clean bad request instead of a generic 500. Mirrors
12 + * {@link ForbiddenOperationExceptionMapper}.
13 + */
14 +@Provider
15 +public class InvalidIssueExceptionMapper implements ExceptionMapper<InvalidIssueException>
16 +{
17 + @Override
18 + public Response toResponse(InvalidIssueException 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/IssueResource.java +167 -0
diff --git a/src/main/java/de/workaround/web/IssueResource.java b/src/main/java/de/workaround/web/IssueResource.java
new file mode 100644
index 0000000..8774fdf
--- /dev/null
+++ b/src/main/java/de/workaround/web/IssueResource.java
@@ -0,0 +1,167 @@
1 +package de.workaround.web;
2 +
3 +import java.net.URI;
4 +import java.util.List;
5 +import java.util.UUID;
6 +
7 +import de.workaround.account.CurrentUser;
8 +import de.workaround.git.AccessPolicy;
9 +import de.workaround.git.GitRepositoryService;
10 +import de.workaround.git.IssueService;
11 +import de.workaround.model.Issue;
12 +import de.workaround.model.Repository;
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.BadRequestException;
18 +import jakarta.ws.rs.Consumes;
19 +import jakarta.ws.rs.FormParam;
20 +import jakarta.ws.rs.GET;
21 +import jakarta.ws.rs.NotFoundException;
22 +import jakarta.ws.rs.POST;
23 +import jakarta.ws.rs.PathParam;
24 +import jakarta.ws.rs.Produces;
25 +import jakarta.ws.rs.core.MediaType;
26 +import jakarta.ws.rs.core.Response;
27 +
28 +@jakarta.ws.rs.Path("/repos/{owner}/{name}/issues")
29 +@Produces(MediaType.TEXT_HTML)
30 +public class IssueResource
31 +{
32 + @CheckedTemplate
33 + static class Templates
34 + {
35 + static native TemplateInstance issues(Repository repo, boolean owner, List<Issue> open, List<Issue> done);
36 +
37 + static native TemplateInstance newIssue(Repository repo);
38 +
39 + static native TemplateInstance issue(Repository repo, boolean owner, Issue issue, List<Issue.Status> statuses);
40 + }
41 +
42 + @Inject
43 + CurrentUser currentUser;
44 +
45 + @Inject
46 + GitRepositoryService service;
47 +
48 + @Inject
49 + AccessPolicy accessPolicy;
50 +
51 + @Inject
52 + IssueService issueService;
53 +
54 + @GET
55 + public TemplateInstance list(@PathParam("owner") String owner, @PathParam("name") String name)
56 + {
57 + Repository repo = requireReadable(owner, name);
58 + User user = currentUser.get();
59 + boolean isOwner = user != null && user.id.equals(repo.owner.id);
60 + List<Issue> all = issueService.list(repo);
61 + // open issues stay visible; DONE issues are tucked into a collapsible archive on the page
62 + List<Issue> open = all.stream().filter(issue -> issue.status != Issue.Status.DONE).toList();
63 + List<Issue> done = all.stream().filter(issue -> issue.status == Issue.Status.DONE).toList();
64 + return Templates.issues(repo, isOwner, open, done);
65 + }
66 +
67 + @GET
68 + @jakarta.ws.rs.Path("new")
69 + public TemplateInstance newForm(@PathParam("owner") String owner, @PathParam("name") String name)
70 + {
71 + Repository repo = requireReadable(owner, name);
72 + // only the owner may open an issue, so only the owner sees the form
73 + if (!accessPolicy.canWrite(currentUser.get(), repo))
74 + {
75 + throw new de.workaround.git.ForbiddenOperationException("Only the repository owner can open issues");
76 + }
77 + return Templates.newIssue(repo);
78 + }
79 +
80 + @POST
81 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
82 + public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
83 + @FormParam("title") String title, @FormParam("description") String description)
84 + {
85 + Repository repo = requireReadable(owner, name);
86 + // title validation lives in IssueService (InvalidIssueException -> 400 via InvalidIssueExceptionMapper)
87 + Issue issue = issueService.create(currentUser.require(), repo, title, description);
88 + return Response.seeOther(issueUri(repo, issue.id)).build();
89 + }
90 +
91 + @GET
92 + @jakarta.ws.rs.Path("{id}")
93 + public TemplateInstance detail(@PathParam("owner") String owner, @PathParam("name") String name,
94 + @PathParam("id") String id)
95 + {
96 + Repository repo = requireReadable(owner, name);
97 + Issue issue = issueService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
98 + User user = currentUser.get();
99 + boolean isOwner = user != null && user.id.equals(repo.owner.id);
100 + return Templates.issue(repo, isOwner, issue, List.of(Issue.Status.values()));
101 + }
102 +
103 + @POST
104 + @jakarta.ws.rs.Path("{id}/status")
105 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
106 + public Response updateStatus(@PathParam("owner") String owner, @PathParam("name") String name,
107 + @PathParam("id") String id, @FormParam("status") String status)
108 + {
109 + Repository repo = requireReadable(owner, name);
110 + Issue issue = issueService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
111 + issueService.updateStatus(currentUser.require(), issue, parseStatus(status));
112 + return Response.seeOther(issueUri(repo, issue.id)).build();
113 + }
114 +
115 + @POST
116 + @jakarta.ws.rs.Path("{id}/delete")
117 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
118 + public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
119 + @PathParam("id") String id)
120 + {
121 + Repository repo = requireReadable(owner, name);
122 + Issue issue = issueService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
123 + issueService.delete(currentUser.require(), issue);
124 + return Response.seeOther(URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/issues")).build();
125 + }
126 +
127 + private URI issueUri(Repository repo, UUID id)
128 + {
129 + return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/issues/" + id);
130 + }
131 +
132 + private static UUID parseId(String id)
133 + {
134 + try
135 + {
136 + return UUID.fromString(id);
137 + }
138 + catch (IllegalArgumentException malformed)
139 + {
140 + throw new NotFoundException();
141 + }
142 + }
143 +
144 + private static Issue.Status parseStatus(String status)
145 + {
146 + try
147 + {
148 + return Issue.Status.valueOf(status);
149 + }
150 + catch (IllegalArgumentException | NullPointerException invalid)
151 + {
152 + throw new BadRequestException("Unknown issue status: " + status);
153 + }
154 + }
155 +
156 + private Repository requireReadable(String owner, String name)
157 + {
158 + Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
159 + if (!accessPolicy.canRead(currentUser.get(), repo))
160 + {
161 + // hide existence of private repositories
162 + throw new NotFoundException();
163 + }
164 + return repo;
165 + }
166 +
167 +}
MODIFY src/main/java/de/workaround/web/RepositoryResource.java +7 -2
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index 6af8566..8d9b23e 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -16,6 +16,7 @@
16 16 import de.workaround.git.AccessPolicy;
17 17 import de.workaround.git.GitBrowseService;
18 18 import de.workaround.git.GitRepositoryService;
19 +import de.workaround.git.IssueService;
19 20 import de.workaround.git.RepositoryPinService;
20 21 import de.workaround.model.Repository;
21 22 import de.workaround.model.User;
@@ -47,7 +48,7 @@
47 48 static native TemplateInstance overview(Repository repo, boolean owner, boolean empty, String defaultBranch,
48 49 List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl, boolean loggedIn,
49 50 boolean pinned, GitBrowseService.CommitInfo latestCommit, String latestCommitAge, int commitCount,
50 - int branchCount, int tagCount);
51 + int branchCount, int tagCount, long openIssueCount);
51 52
52 53 static native TemplateInstance tree(Repository repo, String ref, String path,
53 54 List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs, String activeTab);
@@ -77,6 +78,9 @@
77 78 @Inject
78 79 RepositoryPinService pinService;
79 80
81 + @Inject
82 + IssueService issueService;
83 +
80 84 @ConfigProperty(name = "gitshark.ssh.port")
81 85 int sshPort;
82 86
@@ -106,8 +110,9 @@
106 110 int commitCount = empty ? 0 : browse.commitCount(path, defaultBranch);
107 111 int branchCount = browse.branches(path).size();
108 112 int tagCount = browse.tags(path).size();
113 + long openIssueCount = issueService.countOpen(repo);
109 114 return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo),
110 - loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount);
115 + loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount, openIssueCount);
111 116 }
112 117
113 118 private static String relativeAge(Instant when)
MODIFY src/main/resources/META-INF/resources/shark-hotkeys.js +46 -0
diff --git a/src/main/resources/META-INF/resources/shark-hotkeys.js b/src/main/resources/META-INF/resources/shark-hotkeys.js
index 7f35aa0..ec8362e 100644
--- a/src/main/resources/META-INF/resources/shark-hotkeys.js
+++ b/src/main/resources/META-INF/resources/shark-hotkeys.js
@@ -75,4 +75,50 @@
75 75 target.showModal();
76 76 }
77 77 });
78 +
79 + // Copy-to-clipboard: [data-copy="text"] copies its value and briefly confirms on the button.
80 + document.addEventListener("click", function (event) {
81 + var button = event.target.closest("[data-copy]");
82 + if (!button) {
83 + return;
84 + }
85 + event.preventDefault();
86 + var text = button.getAttribute("data-copy");
87 + if (!navigator.clipboard || !navigator.clipboard.writeText) {
88 + return;
89 + }
90 + navigator.clipboard.writeText(text).then(function () {
91 + if (!button.classList.contains("copied")) {
92 + var original = button.textContent;
93 + button.classList.add("copied");
94 + button.textContent = "✓";
95 + setTimeout(function () {
96 + button.textContent = original;
97 + button.classList.remove("copied");
98 + }, 1200);
99 + }
100 + showCopyToast(button);
101 + }).catch(function () {
102 + // clipboard denied (e.g. insecure context); the command stays visible to copy manually
103 + });
104 + });
105 +
106 + // Brief "Copied!" toast. Appended inside the nearest <dialog> (if any) so it renders on the
107 + // top layer above the modal backdrop; otherwise on <body>.
108 + function showCopyToast(nearButton) {
109 + var container = (nearButton.closest && nearButton.closest("dialog")) || document.body;
110 + var toast = document.createElement("div");
111 + toast.className = "copy-toast";
112 + toast.textContent = "Copied!";
113 + container.appendChild(toast);
114 + requestAnimationFrame(function () {
115 + toast.classList.add("show");
116 + });
117 + setTimeout(function () {
118 + toast.classList.remove("show");
119 + setTimeout(function () {
120 + toast.remove();
121 + }, 200);
122 + }, 1200);
123 + }
78 124 })();
MODIFY src/main/resources/META-INF/resources/shark.css +151 -5
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index d2cc949..df4cda5 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -733,6 +733,111 @@
733 733 border-color: var(--accent);
734 734 }
735 735
736 +/* issues */
737 +
738 +.badge.status-PLANNED {
739 + color: var(--attention);
740 + border-color: var(--attention-border);
741 + background: var(--attention-bg);
742 +}
743 +
744 +.badge.status-IN_DEVELOPMENT {
745 + color: var(--accent-deep);
746 + border-color: transparent;
747 + background: var(--accent-soft);
748 +}
749 +
750 +.badge.status-DONE {
751 + color: #fff;
752 + border-color: var(--success);
753 + background: var(--success);
754 +}
755 +
756 +.issues-head {
757 + display: flex;
758 + align-items: center;
759 + justify-content: space-between;
760 + gap: var(--s3);
761 + margin-bottom: var(--s4);
762 +}
763 +
764 +.issues-head h2 {
765 + margin: 0;
766 +}
767 +
768 +.issue-form {
769 + display: flex;
770 + flex-direction: column;
771 + gap: var(--s2);
772 + max-width: 560px;
773 + margin: 0 0 var(--s5);
774 +}
775 +
776 +.form-actions {
777 + display: flex;
778 + align-items: center;
779 + gap: var(--s2);
780 +}
781 +
782 +details.archive {
783 + margin-top: var(--s5);
784 +}
785 +
786 +details.archive > summary {
787 + display: flex;
788 + align-items: center;
789 + gap: var(--s2);
790 + cursor: pointer;
791 + padding: var(--s2) 0;
792 + font: 600 14px/1 var(--font);
793 + color: var(--muted);
794 + list-style: none;
795 + -webkit-user-select: none;
796 + user-select: none;
797 +}
798 +
799 +details.archive > summary::-webkit-details-marker {
800 + display: none;
801 +}
802 +
803 +details.archive > summary::before {
804 + content: "▸";
805 + color: var(--faint);
806 +}
807 +
808 +details.archive[open] > summary::before {
809 + content: "▾";
810 +}
811 +
812 +details.archive > .panel {
813 + margin-top: var(--s2);
814 +}
815 +
816 +.issue-actions {
817 + display: flex;
818 + align-items: center;
819 + flex-wrap: wrap;
820 + gap: var(--s2);
821 + margin: var(--s4) 0 var(--s3);
822 +}
823 +
824 +.issue-actions .lbl {
825 + color: var(--muted);
826 + font-size: 13px;
827 +}
828 +
829 +.issue-desc {
830 + margin: var(--s3) 0 var(--s5);
831 + white-space: pre-wrap;
832 + word-break: break-word;
833 +}
834 +
835 +.issue-no {
836 + margin-left: auto;
837 + color: var(--faint);
838 + font: 500 12.5px/1 var(--mono);
839 +}
840 +
736 841 /* dashboard */
737 842
738 843 .dashboard-section {
@@ -984,10 +1089,33 @@
984 1089 background: var(--surface);
985 1090 color: var(--ink);
986 1091 padding: var(--s4);
987 - width: min(440px, 92vw);
1092 + width: min(640px, 94vw);
988 1093 box-shadow: 0 20px 60px rgba(0, 0, 0, .25);
989 1094 }
990 1095
1096 +/* copy confirmation toast; appended inside the modal dialog so it paints on the top layer */
1097 +.copy-toast {
1098 + position: fixed;
1099 + left: 50%;
1100 + bottom: 32px;
1101 + transform: translateX(-50%) translateY(8px);
1102 + background: var(--ink);
1103 + color: #fff;
1104 + font: 600 13px/1 var(--font);
1105 + padding: 10px 18px;
1106 + border-radius: 999px;
1107 + box-shadow: 0 8px 24px rgba(0, 0, 0, .3);
1108 + opacity: 0;
1109 + pointer-events: none;
1110 + transition: opacity .15s ease, transform .15s ease;
1111 + z-index: 1000;
1112 +}
1113 +
1114 +.copy-toast.show {
1115 + opacity: 1;
1116 + transform: translateX(-50%) translateY(0);
1117 +}
1118 +
991 1119 .clone-dialog::backdrop {
992 1120 background: rgba(0, 0, 0, .45);
993 1121 }
@@ -1032,18 +1160,36 @@
1032 1160
1033 1161 .clone-url {
1034 1162 display: none;
1035 - overflow-x: auto;
1036 - white-space: nowrap;
1163 + align-items: center;
1164 + gap: var(--s2);
1037 1165 font: 500 12.5px/1.4 var(--mono);
1038 1166 background: var(--canvas);
1039 1167 border: 1px solid var(--border);
1040 1168 border-radius: var(--radius);
1041 - padding: 12px 14px;
1169 + padding: 8px 8px 8px 14px;
1170 +}
1171 +
1172 +.clone-url code {
1173 + flex: 1;
1174 + min-width: 0;
1175 + overflow-x: auto;
1176 + white-space: nowrap;
1177 + background: none;
1178 + color: var(--ink);
1179 + padding: 0;
1180 +}
1181 +
1182 +.clone-url .copy-btn {
1183 + flex: none;
1184 +}
1185 +
1186 +.clone-url .copy-btn.copied {
1187 + color: var(--success-deep);
1042 1188 }
1043 1189
1044 1190 #clone-https:checked ~ .url-https,
1045 1191 #clone-ssh:checked ~ .url-ssh {
1046 - display: block;
1192 + display: flex;
1047 1193 }
1048 1194
1049 1195 /* main column */
ADD src/main/resources/db/migration/V5__issues.sql +13 -0
diff --git a/src/main/resources/db/migration/V5__issues.sql b/src/main/resources/db/migration/V5__issues.sql
new file mode 100644
index 0000000..00f3e24
--- /dev/null
+++ b/src/main/resources/db/migration/V5__issues.sql
@@ -0,0 +1,13 @@
1 +create table issues
2 +(
3 + id uuid primary key,
4 + repository_id uuid not null references repositories (id) on delete cascade,
5 + author_id uuid not null references users (id) on delete cascade,
6 + title text not null,
7 + description text,
8 + status varchar(32) not null default 'PLANNED'
9 + check (status in ('PLANNED', 'IN_DEVELOPMENT', 'DONE')),
10 + created_at timestamptz not null default now()
11 +);
12 +
13 +create index issues_repository_idx on issues (repository_id);
ADD src/main/resources/db/migration/V6__issue_numbers.sql +19 -0
diff --git a/src/main/resources/db/migration/V6__issue_numbers.sql b/src/main/resources/db/migration/V6__issue_numbers.sql
new file mode 100644
index 0000000..78ab972
--- /dev/null
+++ b/src/main/resources/db/migration/V6__issue_numbers.sql
@@ -0,0 +1,19 @@
1 +-- Per-repository, human-facing issue numbers (#1, #2, ...) so commits can reference them GitHub-style.
2 +alter table issues
3 + add column number integer;
4 +
5 +-- Backfill any pre-existing rows: number them per repository in creation order.
6 +with numbered as (
7 + select id, row_number() over (partition by repository_id order by created_at, id) as n
8 + from issues
9 +)
10 +update issues i
11 +set number = numbered.n
12 +from numbered
13 +where numbered.id = i.id;
14 +
15 +alter table issues
16 + alter column number set not null;
17 +
18 +alter table issues
19 + add constraint issues_repository_number_unique unique (repository_id, number);
ADD src/main/resources/templates/IssueResource/issue.html +27 -0
diff --git a/src/main/resources/templates/IssueResource/issue.html b/src/main/resources/templates/IssueResource/issue.html
new file mode 100644
index 0000000..b037058
--- /dev/null
+++ b/src/main/resources/templates/IssueResource/issue.html
@@ -0,0 +1,27 @@
1 +{#include layout}
2 +{#title}{issue.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}/issues">← Issues</a></p>
5 +<h2>{issue.title} <span class="issue-no">#{issue.number}</span></h2>
6 +<p>
7 + <span class="badge status-{issue.status}">{issue.status.label}</span>
8 + <span class="muted">opened by {issue.author.username}</span>
9 +</p>
10 +{#if issue.description}
11 +<pre class="issue-desc">{issue.description}</pre>
12 +{#else}
13 +<p class="muted">No description provided.</p>
14 +{/if}
15 +{#if owner}
16 +<form class="issue-actions" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}/status">
17 + <span class="lbl">Move to</span>
18 + {#for status in statuses}
19 + <button type="submit" class="btn btn-secondary btn-sm" name="status" value="{status}"{#if status == issue.status} disabled{/if}>{status.label}</button>
20 + {/for}
21 +</form>
22 +<form method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}/delete"
23 + onsubmit="return confirm('Delete this issue? This cannot be undone.')">
24 + <button type="submit" class="btn btn-danger">Delete issue</button>
25 +</form>
26 +{/if}
27 +{/include}
ADD src/main/resources/templates/IssueResource/issues.html +41 -0
diff --git a/src/main/resources/templates/IssueResource/issues.html b/src/main/resources/templates/IssueResource/issues.html
new file mode 100644
index 0000000..6bb7679
--- /dev/null
+++ b/src/main/resources/templates/IssueResource/issues.html
@@ -0,0 +1,41 @@
1 +{#include layout}
2 +{#title}Issues – {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>Issues</h2>
6 + {#if owner}
7 + <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/issues/new">New issue</a>
8 + {/if}
9 +</div>
10 +{#if open.isEmpty()}
11 +<p class="muted">No open issues.</p>
12 +{#else}
13 +<div class="panel">
14 + {#for issue in open}
15 + <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}">
16 + <span class="fname">
17 + <span class="badge status-{issue.status}">{issue.status.label}</span>
18 + <span class="n">{issue.title}</span>
19 + <span class="issue-no">#{issue.number}</span>
20 + </span>
21 + </a>
22 + {/for}
23 +</div>
24 +{/if}
25 +{#if done.size > 0}
26 +<details class="archive">
27 + <summary>Archive <span class="ct">{done.size}</span></summary>
28 + <div class="panel">
29 + {#for issue in done}
30 + <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}">
31 + <span class="fname">
32 + <span class="badge status-{issue.status}">{issue.status.label}</span>
33 + <span class="n">{issue.title}</span>
34 + <span class="issue-no">#{issue.number}</span>
35 + </span>
36 + </a>
37 + {/for}
38 + </div>
39 +</details>
40 +{/if}
41 +{/include}
ADD src/main/resources/templates/IssueResource/newIssue.html +14 -0
diff --git a/src/main/resources/templates/IssueResource/newIssue.html b/src/main/resources/templates/IssueResource/newIssue.html
new file mode 100644
index 0000000..a179876
--- /dev/null
+++ b/src/main/resources/templates/IssueResource/newIssue.html
@@ -0,0 +1,14 @@
1 +{#include layout}
2 +{#title}New issue – {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}/issues">← Issues</a></p>
5 +<h2>New issue</h2>
6 +<form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues">
7 + <input type="text" name="title" placeholder="Issue title" required autocomplete="off">
8 + <textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
9 + <div class="form-actions">
10 + <button type="submit" class="btn btn-primary">Create issue</button>
11 + <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/issues">Cancel</a>
12 + </div>
13 +</form>
14 +{/include}
MODIFY src/main/resources/templates/RepositoryResource/overview.html +9 -2
diff --git a/src/main/resources/templates/RepositoryResource/overview.html b/src/main/resources/templates/RepositoryResource/overview.html
index 92d533e..8747027 100644
--- a/src/main/resources/templates/RepositoryResource/overview.html
+++ b/src/main/resources/templates/RepositoryResource/overview.html
@@ -31,6 +31,7 @@
31 31 {/if}
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 + <a href="/repos/{repo.owner.username}/{repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{openIssueCount}</span></a>
34 35 </nav>
35 36 </aside>
36 37
@@ -92,7 +93,13 @@
92 93 <label for="clone-https">HTTPS</label>
93 94 <label for="clone-ssh">SSH</label>
94 95 </div>
95 - <code class="clone-url url-https">git clone {httpUrl}</code>
96 - <code class="clone-url url-ssh">git clone {sshUrl}</code>
96 + <div class="clone-url url-https">
97 + <code>git clone {httpUrl}</code>
98 + <button type="button" class="btn-icon copy-btn" data-copy="git clone {httpUrl}" aria-label="Copy clone command" title="Copy">⧉</button>
99 + </div>
100 + <div class="clone-url url-ssh">
101 + <code>git clone {sshUrl}</code>
102 + <button type="button" class="btn-icon copy-btn" data-copy="git clone {sshUrl}" aria-label="Copy clone command" title="Copy">⧉</button>
103 + </div>
97 104 </dialog>
98 105 {/include}
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 f1e7446..e1d1b9f 100644
--- a/src/main/resources/templates/RepositoryResource/tabs.html
+++ b/src/main/resources/templates/RepositoryResource/tabs.html
@@ -3,5 +3,6 @@
3 3 <a class="tab{#if activeTab == 'files'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/tree/{tabRef}/">Files</a>
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 + <a class="tab{#if activeTab == 'issues'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/issues">Issues</a>
6 7 </nav>
7 8 {/if}
MODIFY src/test/java/de/workaround/git/GitTestSeeder.java +17 -0
diff --git a/src/test/java/de/workaround/git/GitTestSeeder.java b/src/test/java/de/workaround/git/GitTestSeeder.java
index 28025e3..65f926e 100644
--- a/src/test/java/de/workaround/git/GitTestSeeder.java
+++ b/src/test/java/de/workaround/git/GitTestSeeder.java
@@ -5,6 +5,8 @@
5 5 import java.util.Map;
6 6
7 7 import org.eclipse.jgit.api.Git;
8 +import org.eclipse.jgit.lib.ObjectId;
9 +import org.eclipse.jgit.revwalk.RevCommit;
8 10 import org.eclipse.jgit.transport.RefSpec;
9 11
10 12 /** Seeds bare test repositories through a temporary working clone over file://. */
@@ -42,6 +44,21 @@
42 44 }
43 45 }
44 46
47 + /** Pushes a single commit with the given message to refs/heads/main and returns its object id. */
48 + public static ObjectId seedCommit(Path barePath, String message) throws Exception
49 + {
50 + Path work = Files.createTempDirectory("seed");
51 + try (Git git = Git.cloneRepository().setURI(barePath.toUri().toString()).setDirectory(work.toFile()).call())
52 + {
53 + Files.writeString(work.resolve("seed.txt"), message);
54 + git.add().addFilepattern(".").call();
55 + RevCommit commit = git.commit().setMessage(message).setSign(false)
56 + .setAuthor("seed", "seed@example.com").setCommitter("seed", "seed@example.com").call();
57 + git.push().setRefSpecs(new RefSpec("HEAD:refs/heads/main")).call();
58 + return commit.getId();
59 + }
60 + }
61 +
45 62 private static void commit(Git git, String message) throws Exception
46 63 {
47 64 git.commit().setMessage(message).setSign(false)
ADD src/test/java/de/workaround/git/IssueCommitCloserTest.java +90 -0
diff --git a/src/test/java/de/workaround/git/IssueCommitCloserTest.java b/src/test/java/de/workaround/git/IssueCommitCloserTest.java
new file mode 100644
index 0000000..a1aba2b
--- /dev/null
+++ b/src/test/java/de/workaround/git/IssueCommitCloserTest.java
@@ -0,0 +1,90 @@
1 +package de.workaround.git;
2 +
3 +import java.nio.file.Path;
4 +import java.util.List;
5 +import java.util.Set;
6 +import java.util.UUID;
7 +
8 +import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
9 +import org.eclipse.jgit.lib.ObjectId;
10 +import org.eclipse.jgit.transport.ReceiveCommand;
11 +import org.junit.jupiter.api.Test;
12 +
13 +import de.workaround.model.Issue;
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.assertTrue;
22 +
23 +@QuarkusTest
24 +class IssueCommitCloserTest
25 +{
26 + @Inject
27 + IssueCommitCloser closer;
28 +
29 + @Inject
30 + IssueService issueService;
31 +
32 + @Inject
33 + GitRepositoryService service;
34 +
35 + @Inject
36 + Issue.Repo issues;
37 +
38 + @Inject
39 + User.Repo users;
40 +
41 + @Test
42 + void parsesGithubStyleClosingKeywordsCaseInsensitively()
43 + {
44 + assertEquals(Set.of(2), IssueCommitCloser.parseClosedIssues("fix #2"));
45 + assertEquals(Set.of(1, 3), IssueCommitCloser.parseClosedIssues("Closes #1 and resolves #3"));
46 + assertEquals(Set.of(9), IssueCommitCloser.parseClosedIssues("FIXED #9\n\nlong body"));
47 + assertTrue(IssueCommitCloser.parseClosedIssues("mentions #5 but no keyword").isEmpty());
48 + assertTrue(IssueCommitCloser.parseClosedIssues("nothing here").isEmpty());
49 + }
50 +
51 + @Test
52 + void pushingACommitWithAClosingKeywordMovesTheReferencedIssueToDone() throws Exception
53 + {
54 + User owner = persistUser("cc-alice-" + UUID.randomUUID().toString().substring(0, 8));
55 + Repository repo = service.create(owner, "cc", Repository.Visibility.PUBLIC, null);
56 + issueService.create(owner, repo, "first", null); // #1
57 + issueService.create(owner, repo, "second", null); // #2
58 +
59 + Path bare = service.repositoryPath(repo);
60 + ObjectId head = GitTestSeeder.seedCommit(bare, "fix #2 the second thing\n");
61 + ReceiveCommand command = new ReceiveCommand(ObjectId.zeroId(), head, "refs/heads/main");
62 + command.setResult(ReceiveCommand.Result.OK);
63 +
64 + try (org.eclipse.jgit.lib.Repository db = new FileRepositoryBuilder().setGitDir(bare.toFile()).build())
65 + {
66 + closer.onPush(owner.username, "cc", owner.id, db, List.of(command));
67 + }
68 +
69 + assertEquals(Issue.Status.DONE, issues.findByRepositoryAndNumber(repo, 2).orElseThrow().status,
70 + "the commit referenced #2, so it must be closed");
71 + assertEquals(Issue.Status.PLANNED, issues.findByRepositoryAndNumber(repo, 1).orElseThrow().status,
72 + "#1 was not referenced and must stay open");
73 + }
74 +
75 + @Transactional
76 + User persistUser(String name)
77 + {
78 + User existing = users.findByOidcSubOptional(name).orElse(null);
79 + if (existing != null)
80 + {
81 + return existing;
82 + }
83 + User user = new User();
84 + user.oidcSub = name;
85 + user.username = name;
86 + user.persist();
87 + return user;
88 + }
89 +
90 +}
ADD src/test/java/de/workaround/git/IssueServiceTest.java +198 -0
diff --git a/src/test/java/de/workaround/git/IssueServiceTest.java b/src/test/java/de/workaround/git/IssueServiceTest.java
new file mode 100644
index 0000000..a3c3e57
--- /dev/null
+++ b/src/test/java/de/workaround/git/IssueServiceTest.java
@@ -0,0 +1,198 @@
1 +package de.workaround.git;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.model.Issue;
9 +import de.workaround.model.Repository;
10 +import de.workaround.model.User;
11 +import io.quarkus.test.TestTransaction;
12 +import io.quarkus.test.junit.QuarkusTest;
13 +import jakarta.inject.Inject;
14 +import jakarta.persistence.EntityManager;
15 +
16 +import static org.junit.jupiter.api.Assertions.assertEquals;
17 +import static org.junit.jupiter.api.Assertions.assertNull;
18 +import static org.junit.jupiter.api.Assertions.assertThrows;
19 +import static org.junit.jupiter.api.Assertions.assertTrue;
20 +
21 +@QuarkusTest
22 +class IssueServiceTest
23 +{
24 + @Inject
25 + IssueService issueService;
26 +
27 + @Inject
28 + Issue.Repo issues;
29 +
30 + @Inject
31 + Repository.Repo repositories;
32 +
33 + @Inject
34 + EntityManager em;
35 +
36 + @Test
37 + @TestTransaction
38 + void createStoresIssueWithTitleDescriptionAndPlannedStatus()
39 + {
40 + User owner = persistUser("iss-alice");
41 + Repository repo = persistRepo(owner, "r1");
42 +
43 + Issue issue = issueService.create(owner, repo, "Fix login", "It is broken");
44 +
45 + assertEquals("Fix login", issue.title);
46 + assertEquals("It is broken", issue.description);
47 + assertEquals(Issue.Status.PLANNED, issue.status, "new issues start as PLANNED");
48 + assertEquals(owner.id, issue.author.id);
49 + assertEquals(repo.id, issue.repository.id);
50 + }
51 +
52 + @Test
53 + @TestTransaction
54 + void createRejectsBlankTitle()
55 + {
56 + User owner = persistUser("iss-bob");
57 + Repository repo = persistRepo(owner, "r2");
58 +
59 + assertThrows(InvalidIssueException.class, () -> issueService.create(owner, repo, " ", "body"));
60 + }
61 +
62 + @Test
63 + @TestTransaction
64 + void blankDescriptionIsStoredAsNull()
65 + {
66 + User owner = persistUser("iss-cara");
67 + Repository repo = persistRepo(owner, "r3");
68 +
69 + Issue issue = issueService.create(owner, repo, "Title only", " ");
70 +
71 + assertNull(issue.description);
72 + }
73 +
74 + @Test
75 + @TestTransaction
76 + void statusCanBeMovedThroughPlannedInDevelopmentDone()
77 + {
78 + User owner = persistUser("iss-dave");
79 + Repository repo = persistRepo(owner, "r4");
80 + Issue issue = issueService.create(owner, repo, "Ship it", null);
81 +
82 + issueService.updateStatus(owner, issue, Issue.Status.IN_DEVELOPMENT);
83 + assertEquals(Issue.Status.IN_DEVELOPMENT, issues.findById(issue.id).status);
84 +
85 + issueService.updateStatus(owner, issue, Issue.Status.DONE);
86 + assertEquals(Issue.Status.DONE, issues.findById(issue.id).status);
87 + }
88 +
89 + @Test
90 + @TestTransaction
91 + void deleteRemovesTheIssue()
92 + {
93 + User owner = persistUser("iss-erin");
94 + Repository repo = persistRepo(owner, "r5");
95 + Issue issue = issueService.create(owner, repo, "Temporary", null);
96 +
97 + issueService.delete(owner, issue);
98 +
99 + assertTrue(issueService.find(repo, issue.id).isEmpty());
100 + }
101 +
102 + @Test
103 + @TestTransaction
104 + void issuesGetSequentialPerRepositoryNumbers()
105 + {
106 + User owner = persistUser("iss-nils");
107 + Repository repoA = persistRepo(owner, "rna");
108 + Repository repoB = persistRepo(owner, "rnb");
109 +
110 + assertEquals(1, issueService.create(owner, repoA, "a1", null).number);
111 + assertEquals(2, issueService.create(owner, repoA, "a2", null).number);
112 + assertEquals(1, issueService.create(owner, repoB, "b1", null).number, "numbering restarts per repository");
113 + assertEquals(3, issueService.create(owner, repoA, "a3", null).number);
114 + }
115 +
116 + @Test
117 + @TestTransaction
118 + void countOpenCountsPlannedAndInDevelopmentButNotDone()
119 + {
120 + User owner = persistUser("iss-jack");
121 + Repository repo = persistRepo(owner, "rf");
122 + issueService.create(owner, repo, "still planned", null);
123 + Issue inDev = issueService.create(owner, repo, "in progress", null);
124 + Issue finished = issueService.create(owner, repo, "shipped", null);
125 + issueService.updateStatus(owner, inDev, Issue.Status.IN_DEVELOPMENT);
126 + issueService.updateStatus(owner, finished, Issue.Status.DONE);
127 +
128 + assertEquals(2L, issueService.countOpen(repo), "planned + in-development count as open, done does not");
129 + }
130 +
131 + @Test
132 + @TestTransaction
133 + void issuesAreScopedPerRepository()
134 + {
135 + User owner = persistUser("iss-finn");
136 + Repository repoA = persistRepo(owner, "ra");
137 + Repository repoB = persistRepo(owner, "rb");
138 + issueService.create(owner, repoA, "Only in A", null);
139 +
140 + assertEquals(1, issueService.list(repoA).size());
141 + assertTrue(issueService.list(repoB).isEmpty(), "an issue in repo A must not appear in repo B");
142 + }
143 +
144 + @Test
145 + @TestTransaction
146 + void nonOwnerCannotCreateUpdateOrDeleteIssues()
147 + {
148 + User owner = persistUser("iss-gwen");
149 + User stranger = persistUser("iss-hugo");
150 + Repository repo = persistRepo(owner, "rc");
151 + Issue issue = issueService.create(owner, repo, "Owned", null);
152 +
153 + assertThrows(ForbiddenOperationException.class, () -> issueService.create(stranger, repo, "Sneaky", null));
154 + assertThrows(ForbiddenOperationException.class,
155 + () -> issueService.updateStatus(stranger, issue, Issue.Status.DONE));
156 + assertThrows(ForbiddenOperationException.class, () -> issueService.delete(stranger, issue));
157 + }
158 +
159 + @Test
160 + @TestTransaction
161 + void deletingARepositoryRemovesItsIssues()
162 + {
163 + User owner = persistUser("iss-ivy");
164 + Repository repo = persistRepo(owner, "rd");
165 + Issue doomed = issueService.create(owner, repo, "Doomed", null);
166 + UUID doomedId = doomed.id;
167 + UUID repoId = repo.id;
168 +
169 + em.flush();
170 + em.clear();
171 + repositories.deleteById(repoId);
172 + em.flush();
173 + em.clear();
174 +
175 + // scoped to this issue rather than a global count(): other test classes commit issues that persist
176 + assertNull(issues.findById(doomedId), "the issue must be cascade-deleted with its repository");
177 + }
178 +
179 + private Repository persistRepo(User owner, String name)
180 + {
181 + Repository repo = new Repository();
182 + repo.name = name;
183 + repo.owner = owner;
184 + repo.visibility = Repository.Visibility.PUBLIC;
185 + repo.persist();
186 + return repo;
187 + }
188 +
189 + private User persistUser(String name)
190 + {
191 + User user = new User();
192 + user.oidcSub = name + "-" + UUID.randomUUID();
193 + user.username = name + "-" + UUID.randomUUID().toString().substring(0, 8);
194 + user.persist();
195 + return user;
196 + }
197 +
198 +}
ADD src/test/java/de/workaround/web/IssueUiTest.java +187 -0
diff --git a/src/test/java/de/workaround/web/IssueUiTest.java b/src/test/java/de/workaround/web/IssueUiTest.java
new file mode 100644
index 0000000..e5a3f94
--- /dev/null
+++ b/src/test/java/de/workaround/web/IssueUiTest.java
@@ -0,0 +1,187 @@
1 +package de.workaround.web;
2 +
3 +import java.util.UUID;
4 +
5 +import org.junit.jupiter.api.Test;
6 +
7 +import de.workaround.git.GitRepositoryService;
8 +import de.workaround.git.IssueService;
9 +import de.workaround.model.Issue;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.User;
12 +import io.quarkus.test.junit.QuarkusTest;
13 +import io.quarkus.test.security.TestSecurity;
14 +import jakarta.inject.Inject;
15 +import jakarta.transaction.Transactional;
16 +
17 +import static io.restassured.RestAssured.given;
18 +import static org.hamcrest.CoreMatchers.containsString;
19 +import static org.hamcrest.CoreMatchers.not;
20 +
21 +@QuarkusTest
22 +class IssueUiTest
23 +{
24 + @Inject
25 + GitRepositoryService service;
26 +
27 + @Inject
28 + IssueService issueService;
29 +
30 + @Inject
31 + User.Repo userRepo;
32 +
33 + @Test
34 + @TestSecurity(user = "iss-owner")
35 + void ownerCanCreateBrowseTransitionAndDeleteAnIssue()
36 + {
37 + User owner = persistUser("iss-owner");
38 + Repository repo = service.create(owner, "board", Repository.Visibility.PUBLIC, null);
39 + String base = "/repos/" + owner.username + "/board/issues";
40 +
41 + // create (do not follow the redirect: we want to assert the 303 and read Location)
42 + String location = given().redirects().follow(false)
43 + .contentType("application/x-www-form-urlencoded")
44 + .formParam("title", "Broken pipeline").formParam("description", "CI fails on native")
45 + .when().post(base)
46 + .then().statusCode(303)
47 + .extract().header("Location");
48 +
49 + // the issue shows up in the list with its title and default status
50 + given().when().get(base)
51 + .then().statusCode(200)
52 + .body(containsString("Broken pipeline"))
53 + .body(containsString("Planned"));
54 +
55 + // detail page shows the description
56 + given().when().get(location)
57 + .then().statusCode(200)
58 + .body(containsString("CI fails on native"))
59 + .body(containsString("Planned"));
60 +
61 + // move to "In development"
62 + given().redirects().follow(false)
63 + .contentType("application/x-www-form-urlencoded").formParam("status", "IN_DEVELOPMENT")
64 + .when().post(location + "/status")
65 + .then().statusCode(303);
66 + given().when().get(location).then().statusCode(200).body(containsString("In development"));
67 +
68 + // delete
69 + given().redirects().follow(false)
70 + .contentType("application/x-www-form-urlencoded")
71 + .when().post(location + "/delete")
72 + .then().statusCode(303);
73 + given().when().get(base).then().statusCode(200).body(not(containsString("Broken pipeline")));
74 + }
75 +
76 + @Test
77 + @TestSecurity(user = "iss-owner4")
78 + void createIssueIsADedicatedPageLinkedFromTheList()
79 + {
80 + User owner = persistUser("iss-owner4");
81 + service.create(owner, "np", Repository.Visibility.PUBLIC, null);
82 + String base = "/repos/" + owner.username + "/np/issues";
83 +
84 + // the list links to a dedicated new-issue page instead of embedding a create form
85 + given().when().get(base)
86 + .then().statusCode(200)
87 + .body(containsString(base + "/new"));
88 +
89 + // the new-issue page renders a form that posts back to the issues collection
90 + given().when().get(base + "/new")
91 + .then().statusCode(200)
92 + .body(containsString("<form"))
93 + .body(containsString("name=\"title\""))
94 + .body(containsString("action=\"" + base + "\""));
95 + }
96 +
97 + @Test
98 + @TestSecurity(user = "iss-owner5")
99 + void repoOverviewShowsOpenIssueCount()
100 + {
101 + User owner = persistUser("iss-owner5");
102 + Repository repo = service.create(owner, "counts", Repository.Visibility.PUBLIC, null);
103 + issueService.create(owner, repo, "planned a", null);
104 + issueService.create(owner, repo, "planned b", null);
105 + Issue done = issueService.create(owner, repo, "done c", null);
106 + issueService.updateStatus(owner, done, Issue.Status.DONE);
107 +
108 + // empty repo => branch/tag/commit counts are all 0, so "2" can only be the open-issue count
109 + given().when().get("/repos/" + owner.username + "/counts")
110 + .then().statusCode(200)
111 + .body(containsString("Issues"))
112 + .body(containsString("<span class=\"ct\">2</span>"));
113 + }
114 +
115 + @Test
116 + @TestSecurity(user = "iss-owner6")
117 + void doneIssuesGoIntoACollapsibleArchive()
118 + {
119 + User owner = persistUser("iss-owner6");
120 + Repository repo = service.create(owner, "arch", Repository.Visibility.PUBLIC, null);
121 + issueService.create(owner, repo, "Open task", null);
122 + Issue done = issueService.create(owner, repo, "Finished task", null);
123 + issueService.updateStatus(owner, done, Issue.Status.DONE);
124 +
125 + given().when().get("/repos/" + owner.username + "/arch/issues")
126 + .then().statusCode(200)
127 + .body(containsString("Open task"))
128 + .body(containsString("Finished task"))
129 + .body(containsString("#1"))
130 + .body(containsString("#2"))
131 + .body(containsString("<details class=\"archive\""))
132 + .body(containsString("Archive"));
133 + }
134 +
135 + @Test
136 + @TestSecurity(user = "iss-owner7")
137 + void noArchiveSectionWhenNothingIsDone()
138 + {
139 + User owner = persistUser("iss-owner7");
140 + Repository repo = service.create(owner, "noarch", Repository.Visibility.PUBLIC, null);
141 + issueService.create(owner, repo, "Just open", null);
142 +
143 + given().when().get("/repos/" + owner.username + "/noarch/issues")
144 + .then().statusCode(200)
145 + .body(containsString("Just open"))
146 + .body(not(containsString("class=\"archive\"")));
147 + }
148 +
149 + @Test
150 + void anonymousCannotCreateIssues()
151 + {
152 + User owner = persistUser("iss-owner2-" + UUID.randomUUID().toString().substring(0, 8));
153 + Repository repo = service.create(owner, "board", Repository.Visibility.PUBLIC, null);
154 +
155 + given().contentType("application/x-www-form-urlencoded").formParam("title", "Sneaky")
156 + .when().post("/repos/" + owner.username + "/board/issues")
157 + .then().statusCode(403);
158 + }
159 +
160 + @Test
161 + @TestSecurity(user = "iss-stranger")
162 + void privateRepositoryIssuesAreHiddenFromStrangers()
163 + {
164 + persistUser("iss-stranger");
165 + User owner = persistUser("iss-owner3-" + UUID.randomUUID().toString().substring(0, 8));
166 + Repository repo = service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
167 +
168 + given().when().get("/repos/" + owner.username + "/secret/issues")
169 + .then().statusCode(404);
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 +
187 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog