✨ (issues): Add per-repository issue tracker with commit-closing
Changes
23 files changed, +1378 -14
MODIFY
README.md
+4 -1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+}