✨ (issues): Assign an issue to a person
Changes
12 files changed, +319 -9
MODIFY
README.md
+1 -1
@@ -20,7 +20,7 @@
20
20
- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
21
21
- public-key authentication only; keys managed per user in the UI
22
22
- Web UI: an auth-aware header nav (a "Log in" button for visitors; for signed-in users a top-level Following link plus an Account dropdown holding Profile, SSH keys, Access tokens, and Logout — a JS-free `<details>` menu), 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; Markdown files render to HTML by default with a Rendered/Code toggle), a rendered README (commonmark-java with GFM tables, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests, plus Settings for the owner) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
23
-- Per-repository issues: title, optional description (rendered as Markdown, XSS-safe), per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page; title and description can be edited afterwards via an "Edit issue" page. Issue pages are addressed by number (`…/issues/1`); old UUID URLs redirect permanently to the number form
23
+- Per-repository issues: title, optional description (rendered as Markdown, XSS-safe), per-repo sequential number (`#1`, `#2`, …), an author, and an optional assignee (any local user, set by username; blank clears it); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page; title and description can be edited afterwards via an "Edit issue" page. Issue pages are addressed by number (`…/issues/1`); old UUID URLs redirect permanently to the number form
24
24
- 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
25
25
- 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
26
26
- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+2 -2
@@ -31,12 +31,12 @@
31
31
}
32
32
33
33
public record IssueView(int number, String title, String description, Issue.Status status, String author,
34
- Instant createdAt)
34
+ String assignee, Instant createdAt)
35
35
{
36
36
public static IssueView of(Issue issue)
37
37
{
38
38
return new IssueView(issue.number, issue.title, issue.description, issue.status,
39
- issue.author.username, issue.createdAt);
39
+ issue.author.username, issue.assignee == null ? null : issue.assignee.username, issue.createdAt);
40
40
}
41
41
}
42
42
MODIFY
src/main/java/de/workaround/git/IssueService.java
+24 -0
@@ -22,6 +22,9 @@
22
22
Issue.Repo issues;
23
23
24
24
@Inject
25
+ User.Repo users;
26
+
27
+ @Inject
25
28
AccessPolicy accessPolicy;
26
29
27
30
@Transactional
@@ -98,6 +101,27 @@
98
101
}
99
102
}
100
103
104
+ /**
105
+ * Assigns the issue to the local user with the given username, or unassigns it when the username is
106
+ * blank/null. Any existing user can be named — assignment does not require repository access itself.
107
+ */
108
+ @Transactional
109
+ public void assign(User actor, Issue issue, String username)
110
+ {
111
+ requireWrite(actor, issue.repository);
112
+ String handle = username == null ? "" : username.strip();
113
+ User assignee = handle.isEmpty() ? null
114
+ : users.findByUsername(handle)
115
+ .orElseThrow(() -> new InvalidIssueException("No user with that username exists."));
116
+ // re-attach: the issue may have been loaded in a previous request/transaction. It may also have
117
+ // been deleted concurrently since then, so guard against a missing row (findById returns null).
118
+ Issue managed = issues.findById(issue.id);
119
+ if (managed != null)
120
+ {
121
+ managed.assignee = assignee;
122
+ }
123
+ }
124
+
101
125
@Transactional
102
126
public void delete(User actor, Issue issue)
103
127
{
MODIFY
src/main/java/de/workaround/model/Issue.java
+5 -1
@@ -38,6 +38,10 @@
38
38
@ManyToOne(optional = false)
39
39
public User author;
40
40
41
+ /** Optional person responsible for the issue; null means nobody is assigned. Cleared (set null) if that user is deleted. */
42
+ @ManyToOne
43
+ public User assignee;
44
+
41
45
/** Per-repository, human-facing number (#1, #2, ...) assigned on creation; unique within the repository. */
42
46
public int number;
43
47
@@ -67,7 +71,7 @@
67
71
68
72
public interface Repo extends PanacheRepository.Managed<Issue, UUID>
69
73
{
70
- @HQL("select i from Issue i join fetch i.author where i.repository = :repository order by i.createdAt desc")
74
+ @HQL("select i from Issue i join fetch i.author left join fetch i.assignee where i.repository = :repository order by i.createdAt desc")
71
75
List<Issue> findByRepository(Repository repository);
72
76
73
77
@Find
MODIFY
src/main/java/de/workaround/web/IssueResource.java
+35 -2
@@ -1,11 +1,13 @@
1
1
package de.workaround.web;
2
2
3
3
import java.net.URI;
4
+import java.util.ArrayList;
4
5
import java.util.List;
5
6
import java.util.UUID;
6
7
7
8
import de.workaround.account.CurrentUser;
8
9
import de.workaround.git.AccessPolicy;
10
+import de.workaround.git.CollaboratorService;
9
11
import de.workaround.git.GitRepositoryService;
10
12
import de.workaround.git.IssueService;
11
13
import de.workaround.model.Issue;
@@ -42,7 +44,7 @@
42
44
static native TemplateInstance editIssue(Repository repo, RepoNav nav, Issue issue);
43
45
44
46
static native TemplateInstance issue(Repository repo, RepoNav nav, boolean owner, Issue issue,
45
- String descriptionHtml, List<Issue.Status> statuses);
47
+ String descriptionHtml, List<Issue.Status> statuses, List<User> assignees);
46
48
}
47
49
48
50
@Inject
@@ -58,6 +60,9 @@
58
60
IssueService issueService;
59
61
60
62
@Inject
63
+ CollaboratorService collaboratorService;
64
+
65
+ @Inject
61
66
RepoNavService repoNav;
62
67
63
68
@Context
@@ -111,7 +116,22 @@
111
116
boolean isOwner = accessPolicy.canAdmin(user, repo);
112
117
String descriptionHtml = issue.description == null ? null : Markdown.render(issue.description);
113
118
return Templates.issue(repo, repoNav.build(repo, uriInfo), isOwner, issue, descriptionHtml,
114
- List.of(Issue.Status.values()));
119
+ List.of(Issue.Status.values()), assignableUsers(repo));
120
+ }
121
+
122
+ /**
123
+ * Suggestions offered by the assignee autocomplete: the repository owner (for personal repos) plus
124
+ * every collaborator. Assignment itself accepts any username; this list only powers the datalist.
125
+ */
126
+ private List<User> assignableUsers(Repository repo)
127
+ {
128
+ List<User> users = new ArrayList<>();
129
+ if (repo.ownerUser != null)
130
+ {
131
+ users.add(repo.ownerUser);
132
+ }
133
+ collaboratorService.list(repo).forEach(collaborator -> users.add(collaborator.user));
134
+ return users;
115
135
}
116
136
117
137
@GET
@@ -167,6 +187,19 @@
167
187
}
168
188
169
189
@POST
190
+ @jakarta.ws.rs.Path("{number:\\d+}/assign")
191
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
192
+ public Response assign(@PathParam("owner") String owner, @PathParam("name") String name,
193
+ @PathParam("number") int number, @FormParam("assignee") String assignee)
194
+ {
195
+ Repository repo = requireReadable(owner, name);
196
+ Issue issue = issueService.find(repo, number).orElseThrow(NotFoundException::new);
197
+ // username resolution/validation lives in IssueService (InvalidIssueException -> 400 via mapper)
198
+ issueService.assign(currentUser.require(), issue, assignee);
199
+ return Response.seeOther(issueUri(repo, issue.number)).build();
200
+ }
201
+
202
+ @POST
170
203
@jakarta.ws.rs.Path("{number:\\d+}/delete")
171
204
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
172
205
public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
MODIFY
src/main/resources/META-INF/resources/shark.css
+43 -0
@@ -958,6 +958,49 @@
958
958
font: 500 12.5px/1 var(--mono);
959
959
}
960
960
961
+/* issue detail meta line: status badge plus who opened and who is assigned, each with an avatar */
962
+.issue-meta {
963
+ display: flex;
964
+ align-items: center;
965
+ flex-wrap: wrap;
966
+ gap: var(--s3);
967
+ margin: var(--s3) 0 var(--s4);
968
+ font-size: 13px;
969
+ color: var(--muted);
970
+}
971
+
972
+.issue-person {
973
+ display: inline-flex;
974
+ align-items: center;
975
+ gap: 6px;
976
+}
977
+
978
+/* assignee avatar pinned to the right of an issue list row */
979
+.frow-assignee {
980
+ margin-left: auto;
981
+ display: inline-flex;
982
+ align-items: center;
983
+ padding-left: var(--s3);
984
+ flex: none;
985
+}
986
+
987
+/* assignee autocomplete control inside the management bar */
988
+.issue-assign {
989
+ display: flex;
990
+ align-items: center;
991
+ gap: var(--s2);
992
+}
993
+
994
+.issue-assign .lbl {
995
+ color: var(--muted);
996
+ font-size: 13px;
997
+}
998
+
999
+.issue-assign input {
1000
+ width: 150px;
1001
+ max-width: 40vw;
1002
+}
1003
+
961
1004
/* merge requests */
962
1005
963
1006
.badge.status-OPEN {
ADD
src/main/resources/db/migration/V16__issue_assignee.sql
+6 -0
@@ -0,0 +1,6 @@
1
+-- Optional person responsible for an issue. Nullable (unassigned by default) and, unlike author_id,
2
+-- set to null rather than cascade-deleted when the referenced user goes away, so the issue survives.
3
+ALTER TABLE issues
4
+ ADD COLUMN assignee_id uuid references users (id) on delete set null;
5
+
6
+create index issues_assignee_idx on issues (assignee_id);
MODIFY
src/main/resources/templates/IssueResource/issue.html
+19 -3
@@ -5,10 +5,15 @@
5
5
<section class="repo-main">
6
6
<p><a href="/repos/{repo.ownerHandle}/{repo.name}/issues">← Issues</a></p>
7
7
<h2>{issue.title} <span class="issue-no">#{issue.number}</span></h2>
8
- <p>
8
+ <div class="issue-meta">
9
9
<span class="badge status-{issue.status}">{issue.status.label}</span>
10
- <span class="muted">opened by {#avatar user=issue.author /} {issue.author.username}</span>
11
- </p>
10
+ <span class="issue-person">opened by {#avatar user=issue.author /} <span>{issue.author.username}</span></span>
11
+ {#if issue.assignee}
12
+ <span class="issue-person">assigned to {#avatar user=issue.assignee /} <span>{issue.assignee.username}</span></span>
13
+ {#else}
14
+ <span class="issue-person muted">unassigned</span>
15
+ {/if}
16
+ </div>
12
17
{#if descriptionHtml}
13
18
<div class="panel issue-desc">
14
19
<div class="readme-body">{descriptionHtml.raw}</div>
@@ -25,6 +30,17 @@
25
30
<button type="submit" class="btn btn-secondary btn-sm" name="status" value="{status}"{#if status == issue.status} disabled{/if}>{status.label}</button>
26
31
{/for}
27
32
</form>
33
+ <form class="issue-assign" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/assign">
34
+ <span class="lbl">Assignee</span>
35
+ <input type="text" name="assignee" list="assignees" placeholder="username" autocomplete="off"
36
+ value="{issue.assignee.username ?: ''}">
37
+ <datalist id="assignees">
38
+ {#for candidate in assignees}
39
+ <option value="{candidate.username}">
40
+ {/for}
41
+ </datalist>
42
+ <button type="submit" class="btn btn-secondary btn-sm">Assign</button>
43
+ </form>
28
44
<form class="issue-remove" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/delete"
29
45
onsubmit="return confirm('Delete this issue? This cannot be undone.')">
30
46
<button type="submit" class="btn btn-danger btn-sm">Delete issue</button>
MODIFY
src/main/resources/templates/IssueResource/issues.html
+6 -0
@@ -20,6 +20,9 @@
20
20
<span class="n">{issue.title}</span>
21
21
<span class="issue-no">#{issue.number}</span>
22
22
</span>
23
+ {#if issue.assignee}
24
+ <span class="frow-assignee" title="Assigned to {issue.assignee.username}">{#avatar user=issue.assignee /}</span>
25
+ {/if}
23
26
</a>
24
27
{/for}
25
28
</div>
@@ -35,6 +38,9 @@
35
38
<span class="n">{issue.title}</span>
36
39
<span class="issue-no">#{issue.number}</span>
37
40
</span>
41
+ {#if issue.assignee}
42
+ <span class="frow-assignee" title="Assigned to {issue.assignee.username}">{#avatar user=issue.assignee /}</span>
43
+ {/if}
38
44
</a>
39
45
{/for}
40
46
</div>
MODIFY
src/test/java/de/workaround/api/IssueApiTest.java
+1 -0
@@ -45,6 +45,7 @@
45
45
.body("title", equalTo("First bug"))
46
46
.body("status", equalTo("PLANNED"))
47
47
.body("author", equalTo(owner.username))
48
+ .body("assignee", org.hamcrest.Matchers.nullValue())
48
49
.extract().path("number");
49
50
50
51
// list
MODIFY
src/test/java/de/workaround/git/IssueServiceTest.java
+66 -0
@@ -129,6 +129,72 @@
129
129
130
130
@Test
131
131
@TestTransaction
132
+ void newIssuesHaveNoAssignee()
133
+ {
134
+ User owner = persistUser("iss-omar");
135
+ Repository repo = persistRepo(owner, "ra0");
136
+
137
+ Issue issue = issueService.create(owner, repo, "Unassigned", null);
138
+
139
+ assertNull(issue.assignee, "a freshly opened issue is not assigned to anyone");
140
+ }
141
+
142
+ @Test
143
+ @TestTransaction
144
+ void assignSetsTheAssigneeByUsername()
145
+ {
146
+ User owner = persistUser("iss-pia");
147
+ User helper = persistUser("iss-quin");
148
+ Repository repo = persistRepo(owner, "ra1");
149
+ Issue issue = issueService.create(owner, repo, "Needs an owner", null);
150
+
151
+ issueService.assign(owner, issue, helper.username);
152
+
153
+ Issue reloaded = issues.findById(issue.id);
154
+ assertEquals(helper.id, reloaded.assignee.id, "the named user becomes the assignee");
155
+ }
156
+
157
+ @Test
158
+ @TestTransaction
159
+ void assignWithBlankUsernameClearsTheAssignee()
160
+ {
161
+ User owner = persistUser("iss-rob");
162
+ User helper = persistUser("iss-sara");
163
+ Repository repo = persistRepo(owner, "ra2");
164
+ Issue issue = issueService.create(owner, repo, "Reassign me", null);
165
+ issueService.assign(owner, issue, helper.username);
166
+
167
+ issueService.assign(owner, issue, " ");
168
+
169
+ assertNull(issues.findById(issue.id).assignee, "a blank username unassigns the issue");
170
+ }
171
+
172
+ @Test
173
+ @TestTransaction
174
+ void assignRejectsUnknownUsername()
175
+ {
176
+ User owner = persistUser("iss-tom");
177
+ Repository repo = persistRepo(owner, "ra3");
178
+ Issue issue = issueService.create(owner, repo, "Who?", null);
179
+
180
+ assertThrows(InvalidIssueException.class, () -> issueService.assign(owner, issue, "nobody-here"));
181
+ assertNull(issues.findById(issue.id).assignee, "a rejected assignment must not change the issue");
182
+ }
183
+
184
+ @Test
185
+ @TestTransaction
186
+ void nonWriterCannotAssign()
187
+ {
188
+ User owner = persistUser("iss-uma");
189
+ User stranger = persistUser("iss-vic");
190
+ Repository repo = persistRepo(owner, "ra4");
191
+ Issue issue = issueService.create(owner, repo, "Guarded", null);
192
+
193
+ assertThrows(ForbiddenOperationException.class, () -> issueService.assign(stranger, issue, stranger.username));
194
+ }
195
+
196
+ @Test
197
+ @TestTransaction
132
198
void deleteRemovesTheIssue()
133
199
{
134
200
User owner = persistUser("iss-erin");
MODIFY
src/test/java/de/workaround/web/IssueUiTest.java
+111 -0
@@ -4,6 +4,7 @@
4
4
5
5
import org.junit.jupiter.api.Test;
6
6
7
+import de.workaround.git.CollaboratorService;
7
8
import de.workaround.git.GitRepositoryService;
8
9
import de.workaround.git.IssueService;
9
10
import de.workaround.model.Issue;
@@ -28,6 +29,9 @@
28
29
IssueService issueService;
29
30
30
31
@Inject
32
+ CollaboratorService collaboratorService;
33
+
34
+ @Inject
31
35
User.Repo userRepo;
32
36
33
37
@Test
@@ -74,6 +78,113 @@
74
78
}
75
79
76
80
@Test
81
+ @TestSecurity(user = "iss-assign")
82
+ void assigneeInputSuggestsOwnerAndCollaborators()
83
+ {
84
+ User owner = persistUser("iss-assign");
85
+ User collab = persistUser("iss-collab");
86
+ Repository repo = service.create(owner, "assignable", Repository.Visibility.PUBLIC, null);
87
+ collaboratorService.add(owner, repo, collab.username);
88
+ Issue issue = issueService.create(owner, repo, "Assign me", null);
89
+
90
+ // the assign input is backed by a datalist offering the owner and every collaborator
91
+ given().when().get("/repos/" + owner.username + "/assignable/issues/" + issue.number)
92
+ .then().statusCode(200)
93
+ .body(containsString("<datalist id=\"assignees\""))
94
+ .body(containsString("list=\"assignees\""))
95
+ .body(containsString("value=\"iss-assign\""))
96
+ .body(containsString("value=\"iss-collab\""));
97
+ }
98
+
99
+ @Test
100
+ @TestSecurity(user = "iss-assignee-av")
101
+ void issueDetailShowsTheAssigneeWithAvatar()
102
+ {
103
+ User owner = persistUser("iss-assignee-av");
104
+ Repository repo = service.create(owner, "avassign", Repository.Visibility.PUBLIC, null);
105
+ Issue issue = issueService.create(owner, repo, "Has an owner", null);
106
+ issueService.assign(owner, issue, owner.username);
107
+
108
+ // the assignee is named and rendered through the avatar tag (initials fallback here, no uploaded image)
109
+ given().when().get("/repos/" + owner.username + "/avassign/issues/" + issue.number)
110
+ .then().statusCode(200)
111
+ .body(containsString("assigned to"))
112
+ .body(containsString("class=\"av-fallback\""));
113
+ }
114
+
115
+ @Test
116
+ @TestSecurity(user = "iss-assign-http")
117
+ void assigningViaTheFormSetsAndClearsTheAssignee()
118
+ {
119
+ User owner = persistUser("iss-assign-http");
120
+ User helper = persistUser("iss-assign-helper");
121
+ Repository repo = service.create(owner, "httpassign", Repository.Visibility.PUBLIC, null);
122
+ Issue issue = issueService.create(owner, repo, "Assign via form", null);
123
+ String url = "/repos/" + owner.username + "/httpassign/issues/" + issue.number;
124
+
125
+ // happy path: posting a valid username assigns and redirects back to the issue
126
+ given().redirects().follow(false)
127
+ .contentType("application/x-www-form-urlencoded").formParam("assignee", helper.username)
128
+ .when().post(url + "/assign")
129
+ .then().statusCode(303)
130
+ .header("Location", org.hamcrest.Matchers.endsWith(url));
131
+ given().when().get(url)
132
+ .then().statusCode(200)
133
+ .body(containsString("assigned to"))
134
+ .body(containsString(helper.username));
135
+
136
+ // posting a blank username clears the assignment again
137
+ given().redirects().follow(false)
138
+ .contentType("application/x-www-form-urlencoded").formParam("assignee", "")
139
+ .when().post(url + "/assign")
140
+ .then().statusCode(303);
141
+ given().when().get(url).then().statusCode(200).body(containsString("unassigned"));
142
+ }
143
+
144
+ @Test
145
+ @TestSecurity(user = "iss-assign-bad")
146
+ void assigningAnUnknownUsernameIsRejected()
147
+ {
148
+ User owner = persistUser("iss-assign-bad");
149
+ Repository repo = service.create(owner, "badassign", Repository.Visibility.PUBLIC, null);
150
+ Issue issue = issueService.create(owner, repo, "Bad assign", null);
151
+ String url = "/repos/" + owner.username + "/badassign/issues/" + issue.number + "/assign";
152
+
153
+ given().contentType("application/x-www-form-urlencoded").formParam("assignee", "ghost-user")
154
+ .when().post(url)
155
+ .then().statusCode(400);
156
+ }
157
+
158
+ @Test
159
+ void anonymousCannotAssignIssues()
160
+ {
161
+ User owner = persistUser("iss-assign-anon-" + UUID.randomUUID().toString().substring(0, 8));
162
+ Repository repo = service.create(owner, "noassign", Repository.Visibility.PUBLIC, null);
163
+ Issue issue = issueService.create(owner, repo, "Locked assign", null);
164
+ String url = "/repos/" + owner.username + "/noassign/issues/" + issue.number + "/assign";
165
+
166
+ given().contentType("application/x-www-form-urlencoded").formParam("assignee", owner.username)
167
+ .when().post(url)
168
+ .then().statusCode(403);
169
+ }
170
+
171
+ @Test
172
+ @TestSecurity(user = "iss-list-av")
173
+ void issueListShowsTheAssigneeAvatarOnEachRow()
174
+ {
175
+ User owner = persistUser("iss-list-av");
176
+ Repository repo = service.create(owner, "listav", Repository.Visibility.PUBLIC, null);
177
+ Issue issue = issueService.create(owner, repo, "Assigned one", null);
178
+ issueService.assign(owner, issue, owner.username);
179
+
180
+ // assigned issues carry the assignee avatar on the right of their list row
181
+ given().when().get("/repos/" + owner.username + "/listav/issues")
182
+ .then().statusCode(200)
183
+ .body(containsString("class=\"frow-assignee\""))
184
+ .body(containsString("class=\"av-fallback\""));
185
+ }
186
+
187
+ @Test
77
188
@TestSecurity(user = "iss-md")
78
189
void issueDescriptionRendersMarkdown()
79
190
{