✨ (issues): Allow editing issue title and description
Changes
7 files changed, +171 -1
MODIFY
README.md
+1 -1
@@ -15,7 +15,7 @@
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
17
- 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 Collaborators 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
18
-- 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. Issue pages are addressed by number (`…/issues/1`); old UUID URLs redirect permanently to the number form
18
+- 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
19
19
- Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
20
20
- Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
21
21
- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner 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/git/IssueService.java
+20 -0
@@ -65,6 +65,26 @@
65
65
return issues.findByRepositoryAndNumber(repository, number);
66
66
}
67
67
68
+ /** Edits title and description; the number, author, status and creation time never change. */
69
+ @Transactional
70
+ public void update(User actor, Issue issue, String title, String description)
71
+ {
72
+ requireWrite(actor, issue.repository);
73
+ String trimmedTitle = title == null ? "" : title.strip();
74
+ if (trimmedTitle.isEmpty())
75
+ {
76
+ throw new InvalidIssueException("Issue title must not be empty");
77
+ }
78
+ // re-attach: the issue may have been loaded in a previous request/transaction. It may also have
79
+ // been deleted concurrently since then, so guard against a missing row (findById returns null).
80
+ Issue managed = issues.findById(issue.id);
81
+ if (managed != null)
82
+ {
83
+ managed.title = trimmedTitle;
84
+ managed.description = description == null || description.isBlank() ? null : description.strip();
85
+ }
86
+ }
87
+
68
88
@Transactional
69
89
public void updateStatus(User actor, Issue issue, Issue.Status status)
70
90
{
MODIFY
src/main/java/de/workaround/web/IssueResource.java
+31 -0
@@ -39,6 +39,8 @@
39
39
40
40
static native TemplateInstance newIssue(Repository repo, RepoNav nav);
41
41
42
+ static native TemplateInstance editIssue(Repository repo, RepoNav nav, Issue issue);
43
+
42
44
static native TemplateInstance issue(Repository repo, RepoNav nav, boolean owner, Issue issue,
43
45
String descriptionHtml, List<Issue.Status> statuses);
44
46
}
@@ -112,6 +114,35 @@
112
114
List.of(Issue.Status.values()));
113
115
}
114
116
117
+ @GET
118
+ @jakarta.ws.rs.Path("{number:\\d+}/edit")
119
+ public TemplateInstance editForm(@PathParam("owner") String owner, @PathParam("name") String name,
120
+ @PathParam("number") int number)
121
+ {
122
+ Repository repo = requireReadable(owner, name);
123
+ Issue issue = issueService.find(repo, number).orElseThrow(NotFoundException::new);
124
+ // only users with write access (owner or collaborator) may edit an issue
125
+ if (!accessPolicy.canWrite(currentUser.get(), repo))
126
+ {
127
+ throw new de.workaround.git.ForbiddenOperationException("Only the repository owner or a collaborator can edit issues");
128
+ }
129
+ return Templates.editIssue(repo, repoNav.build(repo, uriInfo), issue);
130
+ }
131
+
132
+ @POST
133
+ @jakarta.ws.rs.Path("{number:\\d+}/edit")
134
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
135
+ public Response update(@PathParam("owner") String owner, @PathParam("name") String name,
136
+ @PathParam("number") int number, @FormParam("title") String title,
137
+ @FormParam("description") String description)
138
+ {
139
+ Repository repo = requireReadable(owner, name);
140
+ Issue issue = issueService.find(repo, number).orElseThrow(NotFoundException::new);
141
+ // title validation lives in IssueService (InvalidIssueException -> 400 via InvalidIssueExceptionMapper)
142
+ issueService.update(currentUser.require(), issue, title, description);
143
+ return Response.seeOther(issueUri(repo, issue.number)).build();
144
+ }
145
+
115
146
/** Issues were originally addressed by UUID; keep old bookmarks and federated links working. */
116
147
@GET
117
148
@jakarta.ws.rs.Path("{id:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}")
ADD
src/main/resources/templates/IssueResource/editIssue.html
+18 -0
@@ -0,0 +1,18 @@
1
+{#include layout}
2
+{#title}Edit issue #{issue.number} – {repo.name}{/title}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='issues' /}
5
+ <section class="repo-main">
6
+ <p><a href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}">← {issue.title}</a></p>
7
+ <h2>Edit issue <span class="issue-no">#{issue.number}</span></h2>
8
+ <form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/edit">
9
+ <input type="text" name="title" value="{issue.title}" placeholder="Issue title" required autocomplete="off">
10
+ <textarea name="description" placeholder="Description (optional)" rows="6">{issue.description ?: ''}</textarea>
11
+ <div class="form-actions">
12
+ <button type="submit" class="btn btn-primary">Save changes</button>
13
+ <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}">Cancel</a>
14
+ </div>
15
+ </form>
16
+ </section>
17
+</div>
18
+{/include}
MODIFY
src/main/resources/templates/IssueResource/issue.html
+1 -0
@@ -15,6 +15,7 @@
15
15
<p class="muted">No description provided.</p>
16
16
{/if}
17
17
{#if owner}
18
+ <p><a class="btn btn-secondary btn-sm" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/edit">Edit issue</a></p>
18
19
<form class="issue-actions" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/status">
19
20
<span class="lbl">Move to</span>
20
21
{#for status in statuses}
MODIFY
src/test/java/de/workaround/git/IssueServiceTest.java
+42 -0
@@ -88,6 +88,47 @@
88
88
89
89
@Test
90
90
@TestTransaction
91
+ void updateEditsTitleAndDescription()
92
+ {
93
+ User owner = persistUser("iss-kate");
94
+ Repository repo = persistRepo(owner, "r6");
95
+ Issue issue = issueService.create(owner, repo, "Old title", "old body");
96
+
97
+ issueService.update(owner, issue, " New title ", "new body");
98
+
99
+ Issue reloaded = issues.findById(issue.id);
100
+ assertEquals("New title", reloaded.title, "title is trimmed and updated");
101
+ assertEquals("new body", reloaded.description);
102
+ assertEquals(issue.number, reloaded.number, "editing must not change the number");
103
+ }
104
+
105
+ @Test
106
+ @TestTransaction
107
+ void updateRejectsBlankTitle()
108
+ {
109
+ User owner = persistUser("iss-liam");
110
+ Repository repo = persistRepo(owner, "r7");
111
+ Issue issue = issueService.create(owner, repo, "Keep me", "body");
112
+
113
+ assertThrows(InvalidIssueException.class, () -> issueService.update(owner, issue, " ", "body"));
114
+ assertEquals("Keep me", issues.findById(issue.id).title, "a rejected update must not change the issue");
115
+ }
116
+
117
+ @Test
118
+ @TestTransaction
119
+ void updateWithBlankDescriptionClearsIt()
120
+ {
121
+ User owner = persistUser("iss-mona");
122
+ Repository repo = persistRepo(owner, "r8");
123
+ Issue issue = issueService.create(owner, repo, "Titled", "described");
124
+
125
+ issueService.update(owner, issue, "Titled", " ");
126
+
127
+ assertNull(issues.findById(issue.id).description);
128
+ }
129
+
130
+ @Test
131
+ @TestTransaction
91
132
void deleteRemovesTheIssue()
92
133
{
93
134
User owner = persistUser("iss-erin");
@@ -153,6 +194,7 @@
153
194
assertThrows(ForbiddenOperationException.class, () -> issueService.create(stranger, repo, "Sneaky", null));
154
195
assertThrows(ForbiddenOperationException.class,
155
196
() -> issueService.updateStatus(stranger, issue, Issue.Status.DONE));
197
+ assertThrows(ForbiddenOperationException.class, () -> issueService.update(stranger, issue, "Hijack", null));
156
198
assertThrows(ForbiddenOperationException.class, () -> issueService.delete(stranger, issue));
157
199
}
158
200
MODIFY
src/test/java/de/workaround/web/IssueUiTest.java
+58 -0
@@ -216,6 +216,64 @@
216
216
}
217
217
218
218
@Test
219
+ @TestSecurity(user = "iss-edit")
220
+ void ownerCanEditTitleAndDescription()
221
+ {
222
+ User owner = persistUser("iss-edit");
223
+ Repository repo = service.create(owner, "editable", Repository.Visibility.PUBLIC, null);
224
+ Issue issue = issueService.create(owner, repo, "Old title", "Old **body**");
225
+ String base = "/repos/" + owner.username + "/editable/issues";
226
+ String editUrl = base + "/" + issue.number + "/edit";
227
+
228
+ // the detail page links to the edit form
229
+ given().when().get(base + "/" + issue.number)
230
+ .then().statusCode(200)
231
+ .body(containsString(editUrl));
232
+
233
+ // the edit form is pre-filled with the current title and description
234
+ given().when().get(editUrl)
235
+ .then().statusCode(200)
236
+ .body(containsString("value=\"Old title\""))
237
+ .body(containsString("Old **body**"))
238
+ .body(containsString("action=\"" + editUrl + "\""));
239
+
240
+ // saving redirects back to the detail page
241
+ given().redirects().follow(false)
242
+ .contentType("application/x-www-form-urlencoded")
243
+ .formParam("title", "New title").formParam("description", "New **body**")
244
+ .when().post(editUrl)
245
+ .then().statusCode(303)
246
+ .header("Location", org.hamcrest.Matchers.endsWith(base + "/" + issue.number));
247
+
248
+ // the updated description is rendered as markdown
249
+ given().when().get(base + "/" + issue.number)
250
+ .then().statusCode(200)
251
+ .body(containsString("New title"))
252
+ .body(containsString("<strong>body</strong>"))
253
+ .body(not(containsString("Old title")));
254
+ }
255
+
256
+ @Test
257
+ void anonymousCannotEditIssues()
258
+ {
259
+ User owner = persistUser("iss-edit2-" + UUID.randomUUID().toString().substring(0, 8));
260
+ Repository repo = service.create(owner, "noedit", Repository.Visibility.PUBLIC, null);
261
+ Issue issue = issueService.create(owner, repo, "Locked", null);
262
+ String editUrl = "/repos/" + owner.username + "/noedit/issues/" + issue.number + "/edit";
263
+
264
+ given().when().get(editUrl).then().statusCode(403);
265
+ given().contentType("application/x-www-form-urlencoded")
266
+ .formParam("title", "Hijacked")
267
+ .when().post(editUrl)
268
+ .then().statusCode(403);
269
+
270
+ // visitors don't see the edit link on the detail page
271
+ given().when().get("/repos/" + owner.username + "/noedit/issues/" + issue.number)
272
+ .then().statusCode(200)
273
+ .body(not(containsString("/edit")));
274
+ }
275
+
276
+ @Test
219
277
void anonymousCannotCreateIssues()
220
278
{
221
279
User owner = persistUser("iss-owner2-" + UUID.randomUUID().toString().substring(0, 8));