gitshark

Clone repository

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

← Commits

✨ (issues): Allow editing issue title and description

6b389197c09258ebdf3b35aab2915cc5f53011e6 · Michael Hainz · 2026-07-09T13:58:05Z

Changes

7 files changed, +171 -1

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index 021dde6..a949a66 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/main/java/de/workaround/git/IssueService.java b/src/main/java/de/workaround/git/IssueService.java
index 8a87b4b..e4b37a0 100644
--- a/src/main/java/de/workaround/git/IssueService.java
+++ b/src/main/java/de/workaround/git/IssueService.java
@@ -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
diff --git a/src/main/java/de/workaround/web/IssueResource.java b/src/main/java/de/workaround/web/IssueResource.java
index d96e9dc..109621e 100644
--- a/src/main/java/de/workaround/web/IssueResource.java
+++ b/src/main/java/de/workaround/web/IssueResource.java
@@ -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
diff --git a/src/main/resources/templates/IssueResource/editIssue.html b/src/main/resources/templates/IssueResource/editIssue.html
new file mode 100644
index 0000000..ed455ed
--- /dev/null
+++ b/src/main/resources/templates/IssueResource/editIssue.html
@@ -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
diff --git a/src/main/resources/templates/IssueResource/issue.html b/src/main/resources/templates/IssueResource/issue.html
index f00b69a..07da0b7 100644
--- a/src/main/resources/templates/IssueResource/issue.html
+++ b/src/main/resources/templates/IssueResource/issue.html
@@ -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
diff --git a/src/test/java/de/workaround/git/IssueServiceTest.java b/src/test/java/de/workaround/git/IssueServiceTest.java
index a3c3e57..1bfb5ba 100644
--- a/src/test/java/de/workaround/git/IssueServiceTest.java
+++ b/src/test/java/de/workaround/git/IssueServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/IssueUiTest.java b/src/test/java/de/workaround/web/IssueUiTest.java
index 6d44741..a4c2857 100644
--- a/src/test/java/de/workaround/web/IssueUiTest.java
+++ b/src/test/java/de/workaround/web/IssueUiTest.java
@@ -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));

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog