gitshark

Clone repository

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

← Commits

✨ (issues): Assign an issue to a person

0c3420d56f679f0af2078af9d740be6927ec292e · Phillip Souza Furtner · 2026-07-13T12:46:27Z

Changes

12 files changed, +319 -9

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index c5ffef8..06e97e5 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index e15805b..f349ab0 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -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
diff --git a/src/main/java/de/workaround/git/IssueService.java b/src/main/java/de/workaround/git/IssueService.java
index e4b37a0..bc0a59c 100644
--- a/src/main/java/de/workaround/git/IssueService.java
+++ b/src/main/java/de/workaround/git/IssueService.java
@@ -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
diff --git a/src/main/java/de/workaround/model/Issue.java b/src/main/java/de/workaround/model/Issue.java
index 9d6d05f..c99c28e 100644
--- a/src/main/java/de/workaround/model/Issue.java
+++ b/src/main/java/de/workaround/model/Issue.java
@@ -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
diff --git a/src/main/java/de/workaround/web/IssueResource.java b/src/main/java/de/workaround/web/IssueResource.java
index fd4a082..8f9275d 100644
--- a/src/main/java/de/workaround/web/IssueResource.java
+++ b/src/main/java/de/workaround/web/IssueResource.java
@@ -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
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 12d81c8..bbdee1b 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -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
diff --git a/src/main/resources/db/migration/V16__issue_assignee.sql b/src/main/resources/db/migration/V16__issue_assignee.sql
new file mode 100644
index 0000000..6982603
--- /dev/null
+++ b/src/main/resources/db/migration/V16__issue_assignee.sql
@@ -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
diff --git a/src/main/resources/templates/IssueResource/issue.html b/src/main/resources/templates/IssueResource/issue.html
index 715698c..06a1435 100644
--- a/src/main/resources/templates/IssueResource/issue.html
+++ b/src/main/resources/templates/IssueResource/issue.html
@@ -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
diff --git a/src/main/resources/templates/IssueResource/issues.html b/src/main/resources/templates/IssueResource/issues.html
index fb9d1c8..dd6100f 100644
--- a/src/main/resources/templates/IssueResource/issues.html
+++ b/src/main/resources/templates/IssueResource/issues.html
@@ -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
diff --git a/src/test/java/de/workaround/api/IssueApiTest.java b/src/test/java/de/workaround/api/IssueApiTest.java
index f6e52c8..8abca54 100644
--- a/src/test/java/de/workaround/api/IssueApiTest.java
+++ b/src/test/java/de/workaround/api/IssueApiTest.java
@@ -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
diff --git a/src/test/java/de/workaround/git/IssueServiceTest.java b/src/test/java/de/workaround/git/IssueServiceTest.java
index 0458c4a..6c325de 100644
--- a/src/test/java/de/workaround/git/IssueServiceTest.java
+++ b/src/test/java/de/workaround/git/IssueServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/IssueUiTest.java b/src/test/java/de/workaround/web/IssueUiTest.java
index ae85d07..12ad70f 100644
--- a/src/test/java/de/workaround/web/IssueUiTest.java
+++ b/src/test/java/de/workaround/web/IssueUiTest.java
@@ -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 {

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog