gitshark

Clone repository

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

← Commits

✨ (issues): Address issue pages by per-repo number instead of UUID

380849c50e7099522a235cf1c61b27685dfc8d61 · Michael Hainz · 2026-07-09T13:51:37Z

Changes

5 files changed, +72 -37

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index aa5a1da..021dde6 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
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
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/web/IssueResource.java +29 -30
diff --git a/src/main/java/de/workaround/web/IssueResource.java b/src/main/java/de/workaround/web/IssueResource.java
index 9b4eaee..d96e9dc 100644
--- a/src/main/java/de/workaround/web/IssueResource.java
+++ b/src/main/java/de/workaround/web/IssueResource.java
@@ -95,16 +95,16 @@
95 95 Repository repo = requireReadable(owner, name);
96 96 // title validation lives in IssueService (InvalidIssueException -> 400 via InvalidIssueExceptionMapper)
97 97 Issue issue = issueService.create(currentUser.require(), repo, title, description);
98 - return Response.seeOther(issueUri(repo, issue.id)).build();
98 + return Response.seeOther(issueUri(repo, issue.number)).build();
99 99 }
100 100
101 101 @GET
102 - @jakarta.ws.rs.Path("{id}")
102 + @jakarta.ws.rs.Path("{number:\\d+}")
103 103 public TemplateInstance detail(@PathParam("owner") String owner, @PathParam("name") String name,
104 - @PathParam("id") String id)
104 + @PathParam("number") int number)
105 105 {
106 106 Repository repo = requireReadable(owner, name);
107 - Issue issue = issueService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
107 + Issue issue = issueService.find(repo, number).orElseThrow(NotFoundException::new);
108 108 User user = currentUser.get();
109 109 boolean isOwner = user != null && user.id.equals(repo.owner.id);
110 110 String descriptionHtml = issue.description == null ? null : Markdown.render(issue.description);
@@ -112,45 +112,44 @@
112 112 List.of(Issue.Status.values()));
113 113 }
114 114
115 - @POST
116 - @jakarta.ws.rs.Path("{id}/status")
117 - @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
118 - public Response updateStatus(@PathParam("owner") String owner, @PathParam("name") String name,
119 - @PathParam("id") String id, @FormParam("status") String status)
115 + /** Issues were originally addressed by UUID; keep old bookmarks and federated links working. */
116 + @GET
117 + @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}}")
118 + public Response legacyDetail(@PathParam("owner") String owner, @PathParam("name") String name,
119 + @PathParam("id") UUID id)
120 120 {
121 121 Repository repo = requireReadable(owner, name);
122 - Issue issue = issueService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
123 - issueService.updateStatus(currentUser.require(), issue, parseStatus(status));
124 - return Response.seeOther(issueUri(repo, issue.id)).build();
122 + Issue issue = issueService.find(repo, id).orElseThrow(NotFoundException::new);
123 + return Response.status(Response.Status.MOVED_PERMANENTLY).location(issueUri(repo, issue.number)).build();
125 124 }
126 125
127 126 @POST
128 - @jakarta.ws.rs.Path("{id}/delete")
127 + @jakarta.ws.rs.Path("{number:\\d+}/status")
129 128 @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
130 - public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
131 - @PathParam("id") String id)
129 + public Response updateStatus(@PathParam("owner") String owner, @PathParam("name") String name,
130 + @PathParam("number") int number, @FormParam("status") String status)
132 131 {
133 132 Repository repo = requireReadable(owner, name);
134 - Issue issue = issueService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
133 + Issue issue = issueService.find(repo, number).orElseThrow(NotFoundException::new);
134 + issueService.updateStatus(currentUser.require(), issue, parseStatus(status));
135 + return Response.seeOther(issueUri(repo, issue.number)).build();
136 + }
137 +
138 + @POST
139 + @jakarta.ws.rs.Path("{number:\\d+}/delete")
140 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
141 + public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
142 + @PathParam("number") int number)
143 + {
144 + Repository repo = requireReadable(owner, name);
145 + Issue issue = issueService.find(repo, number).orElseThrow(NotFoundException::new);
135 146 issueService.delete(currentUser.require(), issue);
136 147 return Response.seeOther(URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/issues")).build();
137 148 }
138 149
139 - private URI issueUri(Repository repo, UUID id)
150 + private URI issueUri(Repository repo, int number)
140 151 {
141 - return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/issues/" + id);
142 - }
143 -
144 - private static UUID parseId(String id)
145 - {
146 - try
147 - {
148 - return UUID.fromString(id);
149 - }
150 - catch (IllegalArgumentException malformed)
151 - {
152 - throw new NotFoundException();
153 - }
152 + return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/issues/" + number);
154 153 }
155 154
156 155 private static Issue.Status parseStatus(String status)
MODIFY src/main/resources/templates/IssueResource/issue.html +2 -2
diff --git a/src/main/resources/templates/IssueResource/issue.html b/src/main/resources/templates/IssueResource/issue.html
index aa45f19..f00b69a 100644
--- a/src/main/resources/templates/IssueResource/issue.html
+++ b/src/main/resources/templates/IssueResource/issue.html
@@ -15,13 +15,13 @@
15 15 <p class="muted">No description provided.</p>
16 16 {/if}
17 17 {#if owner}
18 - <form class="issue-actions" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}/status">
18 + <form class="issue-actions" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/status">
19 19 <span class="lbl">Move to</span>
20 20 {#for status in statuses}
21 21 <button type="submit" class="btn btn-secondary btn-sm" name="status" value="{status}"{#if status == issue.status} disabled{/if}>{status.label}</button>
22 22 {/for}
23 23 </form>
24 - <form method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}/delete"
24 + <form method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/delete"
25 25 onsubmit="return confirm('Delete this issue? This cannot be undone.')">
26 26 <button type="submit" class="btn btn-danger">Delete issue</button>
27 27 </form>
MODIFY src/main/resources/templates/IssueResource/issues.html +2 -2
diff --git a/src/main/resources/templates/IssueResource/issues.html b/src/main/resources/templates/IssueResource/issues.html
index 5a2dbae..e72c3a0 100644
--- a/src/main/resources/templates/IssueResource/issues.html
+++ b/src/main/resources/templates/IssueResource/issues.html
@@ -14,7 +14,7 @@
14 14 {#else}
15 15 <div class="panel">
16 16 {#for issue in open}
17 - <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}">
17 + <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}">
18 18 <span class="fname">
19 19 <span class="badge status-{issue.status}">{issue.status.label}</span>
20 20 <span class="n">{issue.title}</span>
@@ -29,7 +29,7 @@
29 29 <summary>Archive <span class="ct">{done.size}</span></summary>
30 30 <div class="panel">
31 31 {#for issue in done}
32 - <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}">
32 + <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}">
33 33 <span class="fname">
34 34 <span class="badge status-{issue.status}">{issue.status.label}</span>
35 35 <span class="n">{issue.title}</span>
MODIFY src/test/java/de/workaround/web/IssueUiTest.java +38 -2
diff --git a/src/test/java/de/workaround/web/IssueUiTest.java b/src/test/java/de/workaround/web/IssueUiTest.java
index c13d35a..6d44741 100644
--- a/src/test/java/de/workaround/web/IssueUiTest.java
+++ b/src/test/java/de/workaround/web/IssueUiTest.java
@@ -83,7 +83,7 @@
83 83 "Some **bold** text\n\n<script>alert('xss')</script>");
84 84
85 85 // markdown is rendered to HTML; embedded raw HTML stays escaped
86 - given().when().get("/repos/" + owner.username + "/mdesc/issues/" + issue.id)
86 + given().when().get("/repos/" + owner.username + "/mdesc/issues/" + issue.number)
87 87 .then().statusCode(200)
88 88 .body(containsString("<strong>bold</strong>"))
89 89 .body(not(containsString("<script>alert('xss')</script>")));
@@ -173,13 +173,49 @@
173 173
174 174 given().when().get(base + "/issues")
175 175 .then().statusCode(200).body(containsString("class=\"repo-nav\""));
176 - given().when().get(base + "/issues/" + issue.id)
176 + given().when().get(base + "/issues/" + issue.number)
177 177 .then().statusCode(200).body(containsString("class=\"repo-nav\""));
178 178 given().when().get(base + "/issues/new")
179 179 .then().statusCode(200).body(containsString("class=\"repo-nav\""));
180 180 }
181 181
182 182 @Test
183 + @TestSecurity(user = "iss-num")
184 + void issuePagesAreAddressedByPerRepoNumberAndOldUuidUrlsRedirect()
185 + {
186 + User owner = persistUser("iss-num");
187 + Repository repo = service.create(owner, "numbered", Repository.Visibility.PUBLIC, null);
188 + String base = "/repos/" + owner.username + "/numbered/issues";
189 + Issue issue = issueService.create(owner, repo, "First", null);
190 +
191 + // creating via the form redirects to the number URL, not the UUID
192 + String location = given().redirects().follow(false)
193 + .contentType("application/x-www-form-urlencoded").formParam("title", "Second")
194 + .when().post(base)
195 + .then().statusCode(303)
196 + .extract().header("Location");
197 + org.junit.jupiter.api.Assertions.assertTrue(location.endsWith(base + "/2"),
198 + "expected redirect to number URL, got: " + location);
199 +
200 + // the detail page is served under the number
201 + given().when().get(base + "/" + issue.number)
202 + .then().statusCode(200).body(containsString("First"));
203 +
204 + // the list links issues by number
205 + given().when().get(base)
206 + .then().statusCode(200).body(containsString(base + "/" + issue.number + "\""));
207 +
208 + // old UUID links redirect permanently to the number URL
209 + given().redirects().follow(false).when().get(base + "/" + issue.id)
210 + .then().statusCode(301)
211 + .header("Location", org.hamcrest.Matchers.endsWith(base + "/" + issue.number));
212 +
213 + // unknown numbers and malformed ids are 404
214 + given().when().get(base + "/999").then().statusCode(404);
215 + given().when().get(base + "/not-a-number").then().statusCode(404);
216 + }
217 +
218 + @Test
183 219 void anonymousCannotCreateIssues()
184 220 {
185 221 User owner = persistUser("iss-owner2-" + UUID.randomUUID().toString().substring(0, 8));

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog