✨ (issues): Address issue pages by per-repo number instead of UUID
Changes
5 files changed, +72 -37
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
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
@@ -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
@@ -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
@@ -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
@@ -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));