fabrica

hanna/fabrica

feat(web): issues — list, create, view, comment, label, assign, close

f06e6d1 · hanna committed on 2026-07-25

Add the issues store module (create with shared per-repo numbering, list by
state with open/closed counts, state/assignee/labels, comments) and the web
issues module: an Issues repo tab (per-repo toggle), open/closed list, new
form, and an issue view with markdown body/comments, a comment box, close/
reopen, an assignee select, and a scoped-label picker. Mutations post to
fixed /issue-new/{repo} and /issue/{id}/* prefixes with CSRF + access checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
10 files changed · +1368 −10UnifiedSplit
Cargo.lock +1 −0
@@ -5629,6 +5629,7 @@ dependencies = [
56295629 "tower-http",
56305630 "tracing",
56315631 "tracing-subscriber",
5632+ "url",
56325633 ]
56335634
56345635 [[package]]
assets/base.css +136 −3
@@ -322,6 +322,137 @@ body.drawer-open .drawer-backdrop {
322322 flex: none;
323323 }
324324
325+/* ---- Issues & pull requests ---- */
326+.issues-head {
327+ display: flex;
328+ justify-content: space-between;
329+ align-items: center;
330+ gap: 1rem;
331+ flex-wrap: wrap;
332+ margin-bottom: 0.5rem;
333+}
334+.issues-head .subnav {
335+ margin: 0;
336+ justify-content: flex-start;
337+}
338+.state-open {
339+ color: var(--fb-success);
340+ display: inline-flex;
341+}
342+.state-closed {
343+ color: var(--fb-danger);
344+ display: inline-flex;
345+}
346+.issue-title-row {
347+ display: flex;
348+ align-items: center;
349+ gap: 0.5rem;
350+ flex-wrap: wrap;
351+}
352+.badge-state-open {
353+ color: var(--fb-success);
354+ border-color: var(--fb-success);
355+}
356+.badge-state-closed {
357+ color: var(--fb-danger);
358+ border-color: var(--fb-danger);
359+}
360+.issue-header {
361+ margin-bottom: 1rem;
362+ padding-bottom: 0.75rem;
363+ border-bottom: 1px solid var(--fb-border);
364+}
365+.issue-header h1 {
366+ margin: 0;
367+}
368+.issue-sub {
369+ display: flex;
370+ align-items: center;
371+ gap: 0.5rem;
372+}
373+.issue-main {
374+ display: flex;
375+ gap: 1.5rem;
376+ align-items: flex-start;
377+}
378+.issue-thread {
379+ flex: 1;
380+ min-width: 0;
381+ display: flex;
382+ flex-direction: column;
383+ gap: 1rem;
384+}
385+.issue-side {
386+ flex: none;
387+ width: 16rem;
388+}
389+.issue-side section {
390+ margin-bottom: 1.25rem;
391+ padding-bottom: 1rem;
392+ border-bottom: 1px solid var(--fb-border);
393+}
394+.issue-side h3 {
395+ font-size: 0.9rem;
396+ margin-bottom: 0.5rem;
397+}
398+.comment {
399+ padding: 0;
400+ overflow: hidden;
401+}
402+.comment-head {
403+ padding: 0.5rem 0.85rem;
404+ border-bottom: 1px solid var(--fb-border);
405+ font-size: 0.9em;
406+}
407+.comment-body {
408+ padding: 0.85rem;
409+}
410+.comment-form textarea {
411+ margin-bottom: 0.5rem;
412+}
413+.comment-actions {
414+ display: flex;
415+ justify-content: flex-end;
416+ gap: 0.5rem;
417+ align-items: center;
418+}
419+.inline-form {
420+ display: inline;
421+}
422+.side-form {
423+ margin-top: 0.5rem;
424+ display: flex;
425+ gap: 0.4rem;
426+ align-items: stretch;
427+ flex-wrap: wrap;
428+}
429+.side-form select {
430+ flex: 1;
431+}
432+.label-set {
433+ display: flex;
434+ flex-wrap: wrap;
435+ gap: 0.35rem;
436+}
437+.label-picker {
438+ flex-direction: column;
439+ align-items: stretch;
440+}
441+.label-picker .checkbox {
442+ display: flex;
443+ align-items: center;
444+ gap: 0.35rem;
445+ margin: 0.15rem 0;
446+}
447+.label-swatch {
448+ width: 0.8rem;
449+ height: 0.8rem;
450+ border-radius: 3px;
451+ background: var(--label-color, #888);
452+ display: inline-block;
453+ flex: none;
454+}
455+
325456 /* Repo settings danger zone. */
326457 .danger-zone {
327458 border-color: var(--fb-danger);
@@ -1682,13 +1813,15 @@ td.diff-empty {
16821813 display: block;
16831814 overflow-x: auto;
16841815 }
1685- /* Stack the profile and dashboard columns on narrow screens. */
1816+ /* Stack the profile, dashboard, and issue columns on narrow screens. */
16861817 .profile-layout,
1687- .dashboard {
1818+ .dashboard,
1819+ .issue-main {
16881820 flex-direction: column;
16891821 }
16901822 .profile-sidebar,
1691- .dashboard-side {
1823+ .dashboard-side,
1824+ .issue-side {
16921825 width: 100%;
16931826 }
16941827 }
crates/store/src/issues.rs +407 −0
@@ -0,0 +1,407 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Issues and pull requests (shared `issues` table), comments, and issue labels.
6+
7+use model::{Comment, Issue, IssueState, Label};
8+use sqlx::any::AnyRow;
9+use sqlx::{AssertSqlSafe, Row};
10+
11+use crate::{Store, StoreError, new_id, now_ms};
12+
13+/// The columns of `issues` in a fixed order.
14+const ISSUE_COLUMNS: &str = "id, repo_id, number, author_id, title, body, state, is_pull, \
15+ assignee_id, created_at, updated_at, closed_at";
16+
17+/// Open/closed counts for a repo's issues or PRs.
18+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19+pub struct StateCounts {
20+ /// Number of open items.
21+ pub open: i64,
22+ /// Number of closed items.
23+ pub closed: i64,
24+}
25+
26+impl Store {
27+ /// Create an issue or PR, allocating the next per-repo number atomically.
28+ ///
29+ /// # Errors
30+ ///
31+ /// Returns [`StoreError::Query`] on a database failure.
32+ pub async fn create_issue(
33+ &self,
34+ repo_id: &str,
35+ author_id: &str,
36+ title: &str,
37+ body: &str,
38+ is_pull: bool,
39+ ) -> Result<Issue, StoreError> {
40+ let mut tx = self.pool.begin().await?;
41+ // Allocate the shared number: read then bump inside the transaction.
42+ let row = sqlx::query("SELECT next_iid FROM repos WHERE id = $1")
43+ .bind(repo_id)
44+ .fetch_one(&mut *tx)
45+ .await?;
46+ let number: i64 = row.try_get("next_iid")?;
47+ sqlx::query("UPDATE repos SET next_iid = $1 WHERE id = $2")
48+ .bind(number + 1)
49+ .bind(repo_id)
50+ .execute(&mut *tx)
51+ .await?;
52+
53+ let now = now_ms();
54+ let issue = Issue {
55+ id: new_id(),
56+ repo_id: repo_id.to_string(),
57+ number,
58+ author_id: author_id.to_string(),
59+ title: title.to_string(),
60+ body: body.to_string(),
61+ state: IssueState::Open,
62+ is_pull,
63+ assignee_id: None,
64+ created_at: now,
65+ updated_at: now,
66+ closed_at: None,
67+ };
68+ let sql = "INSERT INTO issues \
69+ (id, repo_id, number, author_id, title, body, state, is_pull, assignee_id, \
70+ created_at, updated_at, closed_at) \
71+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)";
72+ sqlx::query(sql)
73+ .bind(&issue.id)
74+ .bind(&issue.repo_id)
75+ .bind(issue.number)
76+ .bind(&issue.author_id)
77+ .bind(&issue.title)
78+ .bind(&issue.body)
79+ .bind(issue.state.as_str())
80+ .bind(issue.is_pull)
81+ .bind(&issue.assignee_id)
82+ .bind(issue.created_at)
83+ .bind(issue.updated_at)
84+ .bind(issue.closed_at)
85+ .execute(&mut *tx)
86+ .await?;
87+ tx.commit().await?;
88+ Ok(issue)
89+ }
90+
91+ /// Fetch an issue/PR by repo and number, filtered by kind.
92+ ///
93+ /// # Errors
94+ ///
95+ /// Returns [`StoreError::Query`] on a database failure.
96+ pub async fn issue_by_number(
97+ &self,
98+ repo_id: &str,
99+ number: i64,
100+ is_pull: bool,
101+ ) -> Result<Option<Issue>, StoreError> {
102+ let sql = format!(
103+ "SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = $1 AND number = $2 AND is_pull = $3"
104+ );
105+ let row = sqlx::query(AssertSqlSafe(sql))
106+ .bind(repo_id)
107+ .bind(number)
108+ .bind(is_pull)
109+ .fetch_optional(&self.pool)
110+ .await?;
111+ Ok(row.as_ref().map(|r| self.map_issue(r)).transpose()?)
112+ }
113+
114+ /// Fetch an issue/PR by id.
115+ ///
116+ /// # Errors
117+ ///
118+ /// Returns [`StoreError::Query`] on a database failure.
119+ pub async fn issue_by_id(&self, id: &str) -> Result<Option<Issue>, StoreError> {
120+ let sql = format!("SELECT {ISSUE_COLUMNS} FROM issues WHERE id = $1");
121+ let row = sqlx::query(AssertSqlSafe(sql))
122+ .bind(id)
123+ .fetch_optional(&self.pool)
124+ .await?;
125+ Ok(row.as_ref().map(|r| self.map_issue(r)).transpose()?)
126+ }
127+
128+ /// List a repo's issues or PRs, optionally filtered by state, newest first.
129+ ///
130+ /// # Errors
131+ ///
132+ /// Returns [`StoreError::Query`] on a database failure.
133+ pub async fn list_issues(
134+ &self,
135+ repo_id: &str,
136+ is_pull: bool,
137+ state: Option<IssueState>,
138+ ) -> Result<Vec<Issue>, StoreError> {
139+ let base = format!(
140+ "SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = $1 AND is_pull = $2{} \
141+ ORDER BY number DESC",
142+ if state.is_some() {
143+ " AND state = $3"
144+ } else {
145+ ""
146+ }
147+ );
148+ let mut query = sqlx::query(AssertSqlSafe(base)).bind(repo_id).bind(is_pull);
149+ if let Some(s) = state {
150+ query = query.bind(s.as_str());
151+ }
152+ let rows = query.fetch_all(&self.pool).await?;
153+ rows.iter()
154+ .map(|r| self.map_issue(r))
155+ .collect::<Result<_, _>>()
156+ .map_err(Into::into)
157+ }
158+
159+ /// Open/closed counts for a repo's issues or PRs.
160+ ///
161+ /// # Errors
162+ ///
163+ /// Returns [`StoreError::Query`] on a database failure.
164+ pub async fn issue_counts(
165+ &self,
166+ repo_id: &str,
167+ is_pull: bool,
168+ ) -> Result<StateCounts, StoreError> {
169+ let sql = "SELECT state, COUNT(*) AS n FROM issues \
170+ WHERE repo_id = $1 AND is_pull = $2 GROUP BY state";
171+ let rows = sqlx::query(sql)
172+ .bind(repo_id)
173+ .bind(is_pull)
174+ .fetch_all(&self.pool)
175+ .await?;
176+ let mut counts = StateCounts::default();
177+ for row in &rows {
178+ let state: String = row.try_get("state")?;
179+ let n: i64 = row.try_get("n")?;
180+ if state == "closed" {
181+ counts.closed = n;
182+ } else {
183+ counts.open = n;
184+ }
185+ }
186+ Ok(counts)
187+ }
188+
189+ /// Set an issue/PR's open/closed state (stamping/clearing `closed_at`).
190+ ///
191+ /// # Errors
192+ ///
193+ /// Returns [`StoreError::Query`] on a database failure.
194+ pub async fn set_issue_state(&self, id: &str, state: IssueState) -> Result<bool, StoreError> {
195+ let now = now_ms();
196+ let closed_at = matches!(state, IssueState::Closed).then_some(now);
197+ let updated = sqlx::query(
198+ "UPDATE issues SET state = $1, closed_at = $2, updated_at = $3 WHERE id = $4",
199+ )
200+ .bind(state.as_str())
201+ .bind(closed_at)
202+ .bind(now)
203+ .bind(id)
204+ .execute(&self.pool)
205+ .await?
206+ .rows_affected();
207+ Ok(updated > 0)
208+ }
209+
210+ /// Set (or clear) an issue/PR's assignee.
211+ ///
212+ /// # Errors
213+ ///
214+ /// Returns [`StoreError::Query`] on a database failure.
215+ pub async fn set_issue_assignee(
216+ &self,
217+ id: &str,
218+ assignee_id: Option<&str>,
219+ ) -> Result<bool, StoreError> {
220+ let updated =
221+ sqlx::query("UPDATE issues SET assignee_id = $1, updated_at = $2 WHERE id = $3")
222+ .bind(assignee_id)
223+ .bind(now_ms())
224+ .bind(id)
225+ .execute(&self.pool)
226+ .await?
227+ .rows_affected();
228+ Ok(updated > 0)
229+ }
230+
231+ /// Edit an issue/PR's title and body.
232+ ///
233+ /// # Errors
234+ ///
235+ /// Returns [`StoreError::Query`] on a database failure.
236+ pub async fn update_issue(
237+ &self,
238+ id: &str,
239+ title: &str,
240+ body: &str,
241+ ) -> Result<bool, StoreError> {
242+ let updated =
243+ sqlx::query("UPDATE issues SET title = $1, body = $2, updated_at = $3 WHERE id = $4")
244+ .bind(title)
245+ .bind(body)
246+ .bind(now_ms())
247+ .bind(id)
248+ .execute(&self.pool)
249+ .await?
250+ .rows_affected();
251+ Ok(updated > 0)
252+ }
253+
254+ // ---- Comments ----
255+
256+ /// Add a comment to an issue/PR.
257+ ///
258+ /// # Errors
259+ ///
260+ /// Returns [`StoreError::Query`] on a database failure.
261+ pub async fn add_comment(
262+ &self,
263+ issue_id: &str,
264+ author_id: &str,
265+ body: &str,
266+ ) -> Result<Comment, StoreError> {
267+ let now = now_ms();
268+ let comment = Comment {
269+ id: new_id(),
270+ issue_id: issue_id.to_string(),
271+ author_id: author_id.to_string(),
272+ body: body.to_string(),
273+ created_at: now,
274+ updated_at: now,
275+ };
276+ sqlx::query(
277+ "INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at) \
278+ VALUES ($1, $2, $3, $4, $5, $6)",
279+ )
280+ .bind(&comment.id)
281+ .bind(&comment.issue_id)
282+ .bind(&comment.author_id)
283+ .bind(&comment.body)
284+ .bind(comment.created_at)
285+ .bind(comment.updated_at)
286+ .execute(&self.pool)
287+ .await?;
288+ // Touch the issue so its "updated" reflects the new activity.
289+ let _ = sqlx::query("UPDATE issues SET updated_at = $1 WHERE id = $2")
290+ .bind(now)
291+ .bind(issue_id)
292+ .execute(&self.pool)
293+ .await;
294+ Ok(comment)
295+ }
296+
297+ /// List an issue/PR's comments oldest-first.
298+ ///
299+ /// # Errors
300+ ///
301+ /// Returns [`StoreError::Query`] on a database failure.
302+ pub async fn list_comments(&self, issue_id: &str) -> Result<Vec<Comment>, StoreError> {
303+ let rows = sqlx::query(
304+ "SELECT id, issue_id, author_id, body, created_at, updated_at FROM comments \
305+ WHERE issue_id = $1 ORDER BY created_at ASC",
306+ )
307+ .bind(issue_id)
308+ .fetch_all(&self.pool)
309+ .await?;
310+ rows.iter()
311+ .map(|r| {
312+ Ok(Comment {
313+ id: r.try_get("id")?,
314+ issue_id: r.try_get("issue_id")?,
315+ author_id: r.try_get("author_id")?,
316+ body: r.try_get("body")?,
317+ created_at: r.try_get("created_at")?,
318+ updated_at: r.try_get("updated_at")?,
319+ })
320+ })
321+ .collect::<Result<_, sqlx::Error>>()
322+ .map_err(Into::into)
323+ }
324+
325+ // ---- Issue labels ----
326+
327+ /// The labels applied to an issue/PR.
328+ ///
329+ /// # Errors
330+ ///
331+ /// Returns [`StoreError::Query`] on a database failure.
332+ pub async fn issue_labels(&self, issue_id: &str) -> Result<Vec<Label>, StoreError> {
333+ let sql = "SELECT l.id, l.repo_id, l.name, l.color, l.description, l.created_at \
334+ FROM issue_labels il JOIN labels l ON l.id = il.label_id \
335+ WHERE il.issue_id = $1 ORDER BY l.name_lower ASC";
336+ let rows = sqlx::query(sql)
337+ .bind(issue_id)
338+ .fetch_all(&self.pool)
339+ .await?;
340+ rows.iter()
341+ .map(|r| {
342+ Ok(Label {
343+ id: r.try_get("id")?,
344+ repo_id: r.try_get("repo_id")?,
345+ name: r.try_get("name")?,
346+ color: r.try_get("color")?,
347+ description: r.try_get("description")?,
348+ created_at: r.try_get("created_at")?,
349+ })
350+ })
351+ .collect::<Result<_, sqlx::Error>>()
352+ .map_err(Into::into)
353+ }
354+
355+ /// Add a label to an issue/PR (idempotent).
356+ ///
357+ /// # Errors
358+ ///
359+ /// Returns [`StoreError::Query`] on a database failure.
360+ pub async fn add_issue_label(&self, issue_id: &str, label_id: &str) -> Result<(), StoreError> {
361+ sqlx::query(
362+ "INSERT INTO issue_labels (issue_id, label_id) VALUES ($1, $2) \
363+ ON CONFLICT (issue_id, label_id) DO NOTHING",
364+ )
365+ .bind(issue_id)
366+ .bind(label_id)
367+ .execute(&self.pool)
368+ .await?;
369+ Ok(())
370+ }
371+
372+ /// Remove a label from an issue/PR.
373+ ///
374+ /// # Errors
375+ ///
376+ /// Returns [`StoreError::Query`] on a database failure.
377+ pub async fn remove_issue_label(
378+ &self,
379+ issue_id: &str,
380+ label_id: &str,
381+ ) -> Result<(), StoreError> {
382+ sqlx::query("DELETE FROM issue_labels WHERE issue_id = $1 AND label_id = $2")
383+ .bind(issue_id)
384+ .bind(label_id)
385+ .execute(&self.pool)
386+ .await?;
387+ Ok(())
388+ }
389+
390+ /// Map an `issues` row (selected via [`ISSUE_COLUMNS`]) to an [`Issue`].
391+ fn map_issue(&self, row: &AnyRow) -> Result<Issue, sqlx::Error> {
392+ Ok(Issue {
393+ id: row.try_get("id")?,
394+ repo_id: row.try_get("repo_id")?,
395+ number: row.try_get("number")?,
396+ author_id: row.try_get("author_id")?,
397+ title: row.try_get("title")?,
398+ body: row.try_get("body")?,
399+ state: IssueState::from_token(&row.try_get::<String, _>("state")?),
400+ is_pull: self.get_bool(row, "is_pull")?,
401+ assignee_id: row.try_get("assignee_id")?,
402+ created_at: row.try_get("created_at")?,
403+ updated_at: row.try_get("updated_at")?,
404+ closed_at: row.try_get("closed_at")?,
405+ })
406+ }
407+}
crates/store/src/lib.rs +2 −0
@@ -27,6 +27,7 @@
2727
2828 mod groups;
2929 mod invites;
30+mod issues;
3031 mod keys;
3132 mod labels;
3233 mod repos;
@@ -43,6 +44,7 @@ use sqlx::any::{AnyPoolOptions, AnyRow};
4344 use sqlx::migrate::Migrator;
4445
4546 pub use crate::invites::NewInvite;
47+pub use crate::issues::StateCounts;
4648 pub use crate::keys::NewKey;
4749 pub use crate::repos::{Collaborator, NewRepo};
4850 pub use crate::sessions::NewSession;
crates/web/Cargo.toml +1 −0
@@ -34,6 +34,7 @@ base64 = { workspace = true }
3434 time = { workspace = true }
3535 serde = { workspace = true }
3636 serde_json = { workspace = true }
37+url = { workspace = true }
3738 thiserror = { workspace = true }
3839 tokio = { workspace = true }
3940 tracing = { workspace = true }
crates/web/src/icons.rs +6 −0
@@ -17,6 +17,8 @@ pub enum Icon {
1717 Folder,
1818 File,
1919 GitBranch,
20+ Issue,
21+ GitPull,
2022 History,
2123 ChevronDown,
2224 Search,
@@ -58,6 +60,10 @@ impl Icon {
5860 Icon::GitBranch => {
5961 r#"<path d="M15 6a9 9 0 0 0-9 9V3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>"#
6062 }
63+ Icon::Issue => r#"<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>"#,
64+ Icon::GitPull => {
65+ r#"<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" x2="6" y1="9" y2="21"/>"#
66+ }
6167 Icon::History => {
6268 r#"<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>"#
6369 }
crates/web/src/issues.rs +687 −0
@@ -0,0 +1,687 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Issues: list, create, view, comment, label, assign, and open/close.
6+//!
7+//! Pull requests share the underlying `issues` table and much of this rendering;
8+//! the PR-specific views live in [`crate::pulls`].
9+
10+use axum::Form;
11+use axum::extract::{Path, Query, State};
12+use axum::http::{HeaderMap, Uri};
13+use axum::response::{IntoResponse, Redirect, Response};
14+use axum_extra::extract::cookie::CookieJar;
15+use maud::{Markup, html};
16+use model::{Issue, IssueState, Label, User};
17+use serde::Deserialize;
18+
19+use crate::AppState;
20+use crate::error::{AppError, AppResult};
21+use crate::icons::{Icon, icon};
22+use crate::layout::page;
23+use crate::markdown;
24+use crate::pages::build_chrome;
25+use crate::repo::{RepoCtx, Tab, css_color, label_chip, repo_header};
26+use crate::session::{MaybeUser, verify_csrf};
27+
28+/// Whether these views are for issues or pull requests. Keeps the shared
29+/// rendering parameterized so pulls can reuse it.
30+#[derive(Clone, Copy)]
31+pub(crate) struct Kind {
32+ /// True for pull requests.
33+ pub is_pull: bool,
34+ /// URL segment (`issues` | `pulls`).
35+ pub seg: &'static str,
36+ /// Human noun (`issue` | `pull request`).
37+ pub noun: &'static str,
38+ /// The active repo tab.
39+ pub tab: Tab,
40+}
41+
42+impl Kind {
43+ /// The issues kind.
44+ pub(crate) fn issues() -> Self {
45+ Self {
46+ is_pull: false,
47+ seg: "issues",
48+ noun: "issue",
49+ tab: Tab::Issues,
50+ }
51+ }
52+}
53+
54+/// `GET …/-/issues` — the issue list with open/closed tabs.
55+pub(crate) async fn list(
56+ state: &AppState,
57+ viewer: Option<User>,
58+ jar: CookieJar,
59+ uri: &Uri,
60+ ctx: RepoCtx,
61+ kind: Kind,
62+ show_closed: bool,
63+) -> AppResult<Response> {
64+ if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) {
65+ return Err(AppError::NotFound);
66+ }
67+ let filter = if show_closed {
68+ IssueState::Closed
69+ } else {
70+ IssueState::Open
71+ };
72+ let items = state
73+ .store
74+ .list_issues(&ctx.repo.id, kind.is_pull, Some(filter))
75+ .await?;
76+ let counts = state.store.issue_counts(&ctx.repo.id, kind.is_pull).await?;
77+
78+ // Resolve author usernames and each item's labels.
79+ let mut rows = Vec::with_capacity(items.len());
80+ for issue in &items {
81+ let author = username(state, &issue.author_id).await;
82+ let labels = state.store.issue_labels(&issue.id).await?;
83+ rows.push((issue, author, labels));
84+ }
85+
86+ let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
87+ let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
88+ let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
89+ let body = html! {
90+ (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches))
91+ div class="issues-head" {
92+ nav class="subnav" aria-label=(kind.noun) {
93+ a href=(format!("{base}/-/{}?state=open", kind.seg))
94+ aria-current=[(!show_closed).then_some("page")] {
95+ (icon(Icon::Issue)) span { (counts.open) " Open" }
96+ }
97+ a href=(format!("{base}/-/{}?state=closed", kind.seg))
98+ aria-current=[show_closed.then_some("page")] {
99+ span { (counts.closed) " Closed" }
100+ }
101+ }
102+ @if can_write {
103+ a class="btn btn-primary" href=(format!("{base}/-/{}/new", kind.seg)) {
104+ (icon(Icon::Plus)) span { "New " (kind.noun) }
105+ }
106+ }
107+ }
108+ @if rows.is_empty() {
109+ div class="card" { p class="muted" {
110+ "No " (if show_closed { "closed" } else { "open" }) " " (kind.noun) "s."
111+ } }
112+ } @else {
113+ ul class="entry-list card issue-list" {
114+ @for (issue, author, labels) in &rows {
115+ li class="entry-row" {
116+ div class="entry-row-body" {
117+ div class="issue-title-row" {
118+ (state_icon(issue.state, kind.is_pull))
119+ a class="entry-row-title" href=(format!("{base}/-/{}/{}", kind.seg, issue.number)) {
120+ (issue.title)
121+ }
122+ @for l in labels { (label_chip(l)) }
123+ }
124+ div class="entry-row-meta muted" {
125+ "#" (issue.number) " · opened by " (author)
126+ }
127+ }
128+ }
129+ }
130+ }
131+ }
132+ };
133+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
134+ let title = format!("{}/{}: {}s", ctx.owner.username, ctx.repo.path, kind.noun);
135+ Ok((jar, page(&chrome, &title, body)).into_response())
136+}
137+
138+/// `GET …/-/issues/new` — the new-issue form.
139+pub(crate) async fn new_form(
140+ state: &AppState,
141+ viewer: Option<User>,
142+ jar: CookieJar,
143+ uri: &Uri,
144+ ctx: RepoCtx,
145+ kind: Kind,
146+) -> AppResult<Response> {
147+ if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) {
148+ return Err(AppError::NotFound);
149+ }
150+ // Opening an issue requires Read (the resolve already guaranteed it) and a
151+ // signed-in viewer.
152+ if viewer.is_none() {
153+ return Ok(Redirect::to("/login").into_response());
154+ }
155+ let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
156+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
157+ let body = html! {
158+ (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches))
159+ h2 { "New " (kind.noun) }
160+ div class="card" {
161+ form method="post" action=(format!("/issue-new/{}?is_pull={}", ctx.repo.id, kind.is_pull)) {
162+ input type="hidden" name="_csrf" value=(chrome.csrf);
163+ label for="title" { "Title" }
164+ input type="text" id="title" name="title" required autofocus;
165+ label for="body" { "Description" }
166+ textarea id="body" name="body" rows="8" placeholder="Markdown supported" {}
167+ button class="btn btn-primary" type="submit" { "Create " (kind.noun) }
168+ }
169+ }
170+ };
171+ let title = format!(
172+ "New {} · {}/{}",
173+ kind.noun, ctx.owner.username, ctx.repo.path
174+ );
175+ Ok((jar, page(&chrome, &title, body)).into_response())
176+}
177+
178+/// `GET …/-/issues/{n}` — a single issue with comments and controls.
179+pub(crate) async fn view(
180+ state: &AppState,
181+ viewer: Option<User>,
182+ jar: CookieJar,
183+ uri: &Uri,
184+ ctx: RepoCtx,
185+ kind: Kind,
186+ number: i64,
187+) -> AppResult<Response> {
188+ if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) {
189+ return Err(AppError::NotFound);
190+ }
191+ let issue = state
192+ .store
193+ .issue_by_number(&ctx.repo.id, number, kind.is_pull)
194+ .await?
195+ .ok_or(AppError::NotFound)?;
196+ let author = username(state, &issue.author_id).await;
197+ let comments = state.store.list_comments(&issue.id).await?;
198+ let issue_labels = state.store.issue_labels(&issue.id).await?;
199+ let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
200+ let assignee = match &issue.assignee_id {
201+ Some(id) => Some(username(state, id).await),
202+ None => None,
203+ };
204+
205+ let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
206+ let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id);
207+ let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
208+
209+ let mut comment_rows = Vec::with_capacity(comments.len());
210+ for c in &comments {
211+ comment_rows.push((c, username(state, &c.author_id).await));
212+ }
213+
214+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
215+ let csrf = chrome.csrf.clone();
216+ let body = html! {
217+ (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches))
218+ div class="issue-view" {
219+ header class="issue-header" {
220+ h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }
221+ div class="issue-sub" {
222+ (state_badge(issue.state, kind.is_pull))
223+ span class="muted" { " " (author) " opened this " (kind.noun) }
224+ }
225+ }
226+ div class="issue-main" {
227+ article class="issue-thread" {
228+ (comment_card(&author, &issue.body, issue.created_at))
229+ @for (c, cauthor) in &comment_rows {
230+ (comment_card(cauthor, &c.body, c.created_at))
231+ }
232+ @if can_write {
233+ div class="card comment-form" {
234+ form method="post" action=(format!("/issue/{}/comment", issue.id)) {
235+ input type="hidden" name="_csrf" value=(csrf);
236+ textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {}
237+ div class="comment-actions" {
238+ @if can_write || is_author {
239+ (state_button(&issue, &csrf, kind))
240+ }
241+ button class="btn btn-primary" type="submit" { "Comment" }
242+ }
243+ }
244+ }
245+ }
246+ }
247+ aside class="issue-side" {
248+ section {
249+ h3 { "Assignee" }
250+ @if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } }
251+ @if can_write {
252+ (assignee_form(&issue, &ctx, state, &csrf).await)
253+ }
254+ }
255+ section {
256+ h3 { "Labels" }
257+ @if issue_labels.is_empty() { p class="muted" { "None" } }
258+ @else { div class="label-set" { @for l in &issue_labels { (label_chip(l)) } } }
259+ @if can_write && !repo_labels.is_empty() {
260+ (label_form(&issue, &repo_labels, &issue_labels, &csrf))
261+ }
262+ }
263+ }
264+ }
265+ }
266+ };
267+ let title = format!("{} · {}/{}", issue.title, ctx.owner.username, ctx.repo.path);
268+ Ok((jar, page(&chrome, &title, body)).into_response())
269+}
270+
271+// ---- shared render helpers ----
272+
273+/// A single comment card (also used for the issue body).
274+fn comment_card(author: &str, body: &str, created_at: i64) -> Markup {
275+ html! {
276+ div class="card comment" {
277+ div class="comment-head muted" {
278+ strong { (author) }
279+ span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) }
280+ }
281+ div class="comment-body markdown" { (markdown::render(body)) }
282+ }
283+ }
284+}
285+
286+/// The open/closed state icon.
287+fn state_icon(st: IssueState, is_pull: bool) -> Markup {
288+ let glyph = if is_pull { Icon::GitPull } else { Icon::Issue };
289+ let class = if st == IssueState::Open {
290+ "state-open"
291+ } else {
292+ "state-closed"
293+ };
294+ html! { span class=(class) { (icon(glyph)) } }
295+}
296+
297+/// The open/closed state badge.
298+fn state_badge(st: IssueState, is_pull: bool) -> Markup {
299+ let (class, label) = match st {
300+ IssueState::Open => ("badge badge-state-open", "Open"),
301+ IssueState::Closed => ("badge badge-state-closed", "Closed"),
302+ };
303+ html! {
304+ span class=(class) {
305+ (state_icon(st, is_pull)) " " (label)
306+ }
307+ }
308+}
309+
310+/// The close/reopen button (posts to the state endpoint).
311+fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {
312+ let (target, label) = match issue.state {
313+ IssueState::Open => ("closed", format!("Close {}", kind.noun)),
314+ IssueState::Closed => ("open", format!("Reopen {}", kind.noun)),
315+ };
316+ html! {
317+ form method="post" action=(format!("/issue/{}/state", issue.id)) class="inline-form" {
318+ input type="hidden" name="_csrf" value=(csrf);
319+ input type="hidden" name="state" value=(target);
320+ button class="btn" type="submit" { (label) }
321+ }
322+ }
323+}
324+
325+/// The assignee select form (owner + collaborators).
326+async fn assignee_form(issue: &Issue, ctx: &RepoCtx, state: &AppState, csrf: &str) -> Markup {
327+ let mut options: Vec<(String, String)> =
328+ vec![(ctx.owner.id.clone(), ctx.owner.username.clone())];
329+ if let Ok(collabs) = state.store.list_collaborators(&ctx.repo.id).await {
330+ for c in collabs {
331+ options.push((c.user_id, c.username));
332+ }
333+ }
334+ html! {
335+ form method="post" action=(format!("/issue/{}/assignee", issue.id)) class="side-form" {
336+ input type="hidden" name="_csrf" value=(csrf);
337+ select name="assignee_id" aria-label="Assignee" {
338+ option value="" selected[issue.assignee_id.is_none()] { "No one" }
339+ @for (id, name) in &options {
340+ option value=(id) selected[issue.assignee_id.as_deref() == Some(id.as_str())] { (name) }
341+ }
342+ }
343+ button class="btn" type="submit" { "Set" }
344+ }
345+ }
346+}
347+
348+/// The label-toggle form: checkboxes for each repo label.
349+fn label_form(issue: &Issue, repo_labels: &[Label], applied: &[Label], csrf: &str) -> Markup {
350+ let is_on = |id: &str| applied.iter().any(|l| l.id == id);
351+ html! {
352+ form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" {
353+ input type="hidden" name="_csrf" value=(csrf);
354+ @for l in repo_labels {
355+ label class="checkbox" {
356+ input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)];
357+ " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}
358+ " " (l.name)
359+ }
360+ }
361+ button class="btn" type="submit" { "Apply labels" }
362+ }
363+ }
364+}
365+
366+/// Resolve a username for display, falling back to the id.
367+async fn username(state: &AppState, user_id: &str) -> String {
368+ state
369+ .store
370+ .user_by_id(user_id)
371+ .await
372+ .ok()
373+ .flatten()
374+ .map_or_else(|| user_id.to_string(), |u| u.username)
375+}
376+
377+// ---- POST handlers (fixed-prefix routes, keyed by id) ----
378+
379+/// Load an issue's repo and the viewer's access to it.
380+async fn repo_of_issue(
381+ state: &AppState,
382+ viewer: Option<&User>,
383+ issue: &Issue,
384+) -> AppResult<(model::Repo, auth::Access)> {
385+ let repo = state
386+ .store
387+ .repo_by_id(&issue.repo_id)
388+ .await?
389+ .ok_or(AppError::NotFound)?;
390+ let access = access_for(state, viewer, &repo).await?;
391+ Ok((repo, access))
392+}
393+
394+/// Compute the viewer's access to a repo.
395+async fn access_for(
396+ state: &AppState,
397+ viewer: Option<&User>,
398+ repo: &model::Repo,
399+) -> AppResult<auth::Access> {
400+ let viewer_id = viewer.map(|u| auth::Viewer {
401+ id: u.id.clone(),
402+ is_admin: u.is_admin,
403+ });
404+ let collab = match viewer {
405+ Some(u) => state
406+ .store
407+ .collaborator_permission(&repo.id, &u.id)
408+ .await?
409+ .and_then(|p| auth::Permission::parse(&p)),
410+ None => None,
411+ };
412+ Ok(auth::access(
413+ viewer_id.as_ref(),
414+ repo,
415+ collab,
416+ state.config.instance.allow_anonymous,
417+ ))
418+}
419+
420+/// The path to an issue/PR.
421+async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String {
422+ let owner = username(state, &repo.owner_id).await;
423+ let seg = if issue.is_pull { "pulls" } else { "issues" };
424+ format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number)
425+}
426+
427+/// New-issue form fields + a query flag for `is_pull`.
428+#[derive(Debug, Deserialize)]
429+pub struct NewIssueForm {
430+ /// Title.
431+ #[serde(default)]
432+ title: String,
433+ /// Markdown body.
434+ #[serde(default)]
435+ body: String,
436+ /// CSRF token.
437+ #[serde(rename = "_csrf")]
438+ csrf: String,
439+}
440+
441+/// `?is_pull=` query for the create endpoint.
442+#[derive(Debug, Default, Deserialize)]
443+pub struct CreateQuery {
444+ /// Whether the new item is a pull request.
445+ #[serde(default)]
446+ is_pull: bool,
447+}
448+
449+/// `POST /issue-new/{repo_id}` — create an issue (PRs use their own flow).
450+pub async fn create(
451+ State(state): State<AppState>,
452+ MaybeUser(viewer): MaybeUser,
453+ jar: CookieJar,
454+ headers: HeaderMap,
455+ Path(repo_id): Path<String>,
456+ Query(q): Query<CreateQuery>,
457+ Form(form): Form<NewIssueForm>,
458+) -> Response {
459+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
460+ return AppError::Forbidden.into_response();
461+ }
462+ let result = async {
463+ let user = viewer.ok_or(AppError::Forbidden)?;
464+ let repo = state
465+ .store
466+ .repo_by_id(&repo_id)
467+ .await?
468+ .ok_or(AppError::NotFound)?;
469+ if q.is_pull || !repo.issues_enabled {
470+ return Err(AppError::NotFound);
471+ }
472+ // Read access is enough to open an issue.
473+ if access_for(&state, Some(&user), &repo).await? < auth::Access::Read {
474+ return Err(AppError::NotFound);
475+ }
476+ let title = form.title.trim();
477+ if title.is_empty() {
478+ return Err(AppError::BadRequest("A title is required.".to_string()));
479+ }
480+ let issue = state
481+ .store
482+ .create_issue(&repo.id, &user.id, title, form.body.trim(), false)
483+ .await?;
484+ Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
485+ }
486+ .await;
487+ respond_redirect(result)
488+}
489+
490+/// Comment form.
491+#[derive(Debug, Deserialize)]
492+pub struct CommentForm {
493+ /// Markdown body.
494+ #[serde(default)]
495+ body: String,
496+ /// CSRF token.
497+ #[serde(rename = "_csrf")]
498+ csrf: String,
499+}
500+
501+/// `POST /issue/{id}/comment` — add a comment.
502+pub async fn comment(
503+ State(state): State<AppState>,
504+ MaybeUser(viewer): MaybeUser,
505+ jar: CookieJar,
506+ headers: HeaderMap,
507+ Path(id): Path<String>,
508+ Form(form): Form<CommentForm>,
509+) -> Response {
510+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
511+ return AppError::Forbidden.into_response();
512+ }
513+ let result = async {
514+ let user = viewer.ok_or(AppError::Forbidden)?;
515+ let issue = state
516+ .store
517+ .issue_by_id(&id)
518+ .await?
519+ .ok_or(AppError::NotFound)?;
520+ let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
521+ if access < auth::Access::Read {
522+ return Err(AppError::NotFound);
523+ }
524+ let body = form.body.trim();
525+ if !body.is_empty() {
526+ state.store.add_comment(&issue.id, &user.id, body).await?;
527+ }
528+ Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
529+ }
530+ .await;
531+ respond_redirect(result)
532+}
533+
534+/// State-change form.
535+#[derive(Debug, Deserialize)]
536+pub struct StateForm {
537+ /// `open` | `closed`.
538+ #[serde(default)]
539+ state: String,
540+ /// CSRF token.
541+ #[serde(rename = "_csrf")]
542+ csrf: String,
543+}
544+
545+/// `POST /issue/{id}/state` — open/close (author or Write).
546+pub async fn set_state(
547+ State(state): State<AppState>,
548+ MaybeUser(viewer): MaybeUser,
549+ jar: CookieJar,
550+ headers: HeaderMap,
551+ Path(id): Path<String>,
552+ Form(form): Form<StateForm>,
553+) -> Response {
554+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
555+ return AppError::Forbidden.into_response();
556+ }
557+ let result = async {
558+ let user = viewer.ok_or(AppError::Forbidden)?;
559+ let issue = state
560+ .store
561+ .issue_by_id(&id)
562+ .await?
563+ .ok_or(AppError::NotFound)?;
564+ let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
565+ let allowed = access >= auth::Access::Write || user.id == issue.author_id;
566+ if !allowed {
567+ return Err(AppError::Forbidden);
568+ }
569+ state
570+ .store
571+ .set_issue_state(&issue.id, IssueState::from_token(&form.state))
572+ .await?;
573+ Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
574+ }
575+ .await;
576+ respond_redirect(result)
577+}
578+
579+/// Assignee form.
580+#[derive(Debug, Deserialize)]
581+pub struct AssigneeForm {
582+ /// The assignee user id (empty clears it).
583+ #[serde(default)]
584+ assignee_id: String,
585+ /// CSRF token.
586+ #[serde(rename = "_csrf")]
587+ csrf: String,
588+}
589+
590+/// `POST /issue/{id}/assignee` — set/clear the assignee (Write).
591+pub async fn set_assignee(
592+ State(state): State<AppState>,
593+ MaybeUser(viewer): MaybeUser,
594+ jar: CookieJar,
595+ headers: HeaderMap,
596+ Path(id): Path<String>,
597+ Form(form): Form<AssigneeForm>,
598+) -> Response {
599+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
600+ return AppError::Forbidden.into_response();
601+ }
602+ let result = async {
603+ let user = viewer.ok_or(AppError::Forbidden)?;
604+ let issue = state
605+ .store
606+ .issue_by_id(&id)
607+ .await?
608+ .ok_or(AppError::NotFound)?;
609+ let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
610+ if access < auth::Access::Write {
611+ return Err(AppError::Forbidden);
612+ }
613+ let assignee = form.assignee_id.trim();
614+ state
615+ .store
616+ .set_issue_assignee(&issue.id, (!assignee.is_empty()).then_some(assignee))
617+ .await?;
618+ Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
619+ }
620+ .await;
621+ respond_redirect(result)
622+}
623+
624+/// `POST /issue/{id}/labels` — replace the applied labels (Write). Scoped labels
625+/// are kept mutually exclusive within their scope by the checkbox set itself.
626+pub async fn set_labels(
627+ State(state): State<AppState>,
628+ MaybeUser(viewer): MaybeUser,
629+ jar: CookieJar,
630+ headers: HeaderMap,
631+ Path(id): Path<String>,
632+ body: axum::body::Bytes,
633+) -> Response {
634+ // Parse the repeated `label` fields and `_csrf` from the urlencoded body
635+ // (serde_urlencoded has no sequence support).
636+ let mut chosen: Vec<String> = Vec::new();
637+ let mut csrf = String::new();
638+ for (k, v) in url::form_urlencoded::parse(&body) {
639+ match k.as_ref() {
640+ "label" => chosen.push(v.into_owned()),
641+ "_csrf" => csrf = v.into_owned(),
642+ _ => {}
643+ }
644+ }
645+ if verify_csrf(&jar, &headers, Some(&csrf)).is_err() {
646+ return AppError::Forbidden.into_response();
647+ }
648+ let result = async {
649+ let user = viewer.ok_or(AppError::Forbidden)?;
650+ let issue = state
651+ .store
652+ .issue_by_id(&id)
653+ .await?
654+ .ok_or(AppError::NotFound)?;
655+ let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
656+ if access < auth::Access::Write {
657+ return Err(AppError::Forbidden);
658+ }
659+ // Only accept labels that belong to this repo.
660+ let repo_labels = state.store.list_labels(&repo.id).await?;
661+ let valid: std::collections::HashSet<&str> =
662+ repo_labels.iter().map(|l| l.id.as_str()).collect();
663+ let applied = state.store.issue_labels(&issue.id).await?;
664+ // Remove those unchecked, add those newly checked.
665+ for l in &applied {
666+ if !chosen.iter().any(|c| c == &l.id) {
667+ state.store.remove_issue_label(&issue.id, &l.id).await?;
668+ }
669+ }
670+ for c in &chosen {
671+ if valid.contains(c.as_str()) && !applied.iter().any(|l| &l.id == c) {
672+ state.store.add_issue_label(&issue.id, c).await?;
673+ }
674+ }
675+ Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
676+ }
677+ .await;
678+ respond_redirect(result)
679+}
680+
681+/// Redirect on success, render the error otherwise.
682+fn respond_redirect(result: AppResult<String>) -> Response {
683+ match result {
684+ Ok(dest) => Redirect::to(&dest).into_response(),
685+ Err(err) => err.into_response(),
686+ }
687+}
crates/web/src/lib.rs +7 −0
@@ -17,6 +17,7 @@ mod diff;
1717 mod error;
1818 mod git_http;
1919 mod icons;
20+mod issues;
2021 mod layout;
2122 mod markdown;
2223 mod pages;
@@ -176,6 +177,12 @@ pub fn build_router(state: AppState) -> Router {
176177 "/repo-settings/{id}/labels/delete",
177178 post(repo::settings_label_delete),
178179 )
180+ // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
181+ .route("/issue-new/{repo_id}", post(issues::create))
182+ .route("/issue/{id}/comment", post(issues::comment))
183+ .route("/issue/{id}/state", post(issues::set_state))
184+ .route("/issue/{id}/assignee", post(issues::set_assignee))
185+ .route("/issue/{id}/labels", post(issues::set_labels))
179186 .route("/healthz", get(serve_assets::healthz))
180187 .route("/version", get(serve_assets::version))
181188 .route("/theme.css", get(serve_assets::theme_default))
crates/web/src/repo.rs +29 −7
@@ -58,8 +58,10 @@ const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"
5858
5959 /// Which repo tab is active, for highlighting.
6060 #[derive(Clone, Copy, PartialEq, Eq)]
61-enum Tab {
61+pub(crate) enum Tab {
6262 Code,
63+ Issues,
64+ Pulls,
6365 Commits,
6466 Branches,
6567 Tags,
@@ -97,7 +99,7 @@ where
9799
98100 /// The local branch names for the switcher, empty on any error (an empty repo or
99101 /// a failed read simply yields no dropdown).
100-async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> {
102+pub(crate) async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> {
101103 git_read(state, repo_id, git::Repo::branch_names)
102104 .await
103105 .unwrap_or_default()
@@ -108,7 +110,7 @@ async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> {
108110 ///
109111 /// Denied access to an existing private repo returns `Err(NotFound)` — identical
110112 /// to a missing repo, so private existence never leaks.
111-async fn resolve(
113+pub(crate) async fn resolve(
112114 state: &AppState,
113115 owner_name: &str,
114116 repo_path: &str,
@@ -289,6 +291,20 @@ async fn dispatch_view(
289291 let raw_query = dq.q.clone().unwrap_or_default();
290292 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
291293 }
294+ "issues" => {
295+ let kind = crate::issues::Kind::issues();
296+ match rest {
297+ "" => {
298+ let closed = uri.query().is_some_and(|q| q.contains("state=closed"));
299+ crate::issues::list(state, viewer, jar, uri, ctx, kind, closed).await
300+ }
301+ "new" => crate::issues::new_form(state, viewer, jar, uri, ctx, kind).await,
302+ n => match n.parse::<i64>() {
303+ Ok(num) => crate::issues::view(state, viewer, jar, uri, ctx, kind, num).await,
304+ Err(_) => Err(AppError::NotFound),
305+ },
306+ }
307+ }
292308 "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,
293309 // Reserved `runs` slot returns 404 until built.
294310 _ => Err(AppError::NotFound),
@@ -839,7 +855,7 @@ fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collabora
839855 }
840856
841857 /// A coloured label chip.
842-fn label_chip(label: &model::Label) -> Markup {
858+pub(crate) fn label_chip(label: &model::Label) -> Markup {
843859 html! {
844860 span class="label-chip" style=(format!("--label-color: {}", css_color(&label.color))) {
845861 (label.name)
@@ -848,7 +864,7 @@ fn label_chip(label: &model::Label) -> Markup {
848864 }
849865
850866 /// Sanitize a stored colour for inline CSS: only `#` + up to 8 hex digits.
851-fn css_color(color: &str) -> String {
867+pub(crate) fn css_color(color: &str) -> String {
852868 let hex: String = color
853869 .trim_start_matches('#')
854870 .chars()
@@ -1930,7 +1946,7 @@ fn link_icon(url: &str) -> Icon {
19301946
19311947 /// The repo sub-header: slug, description, and a tab strip sharing its line with
19321948 /// the branch switcher and clone menu.
1933-fn repo_header(
1949+pub(crate) fn repo_header(
19341950 state: &AppState,
19351951 ctx: &RepoCtx,
19361952 active: Tab,
@@ -1958,6 +1974,12 @@ fn repo_header(
19581974 div class="repo-nav" {
19591975 nav class="tabs repo-tabs" {
19601976 (tab(Tab::Code, "Code", Icon::File, base.clone()))
1977+ @if ctx.repo.issues_enabled {
1978+ (tab(Tab::Issues, "Issues", Icon::Issue, format!("{base}/-/issues")))
1979+ }
1980+ @if ctx.repo.pulls_enabled {
1981+ (tab(Tab::Pulls, "Pull requests", Icon::GitPull, format!("{base}/-/pulls")))
1982+ }
19611983 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))
19621984 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
19631985 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
@@ -2273,7 +2295,7 @@ pub(crate) fn fmt_joined(ms: i64) -> String {
22732295 }
22742296
22752297 /// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date.
2276-fn fmt_date(ms: i64) -> String {
2298+pub(crate) fn fmt_date(ms: i64) -> String {
22772299 let secs = ms.div_euclid(1000);
22782300 match time::OffsetDateTime::from_unix_timestamp(secs) {
22792301 Ok(dt) => format!(
crates/web/src/tests.rs +92 −0
@@ -530,6 +530,98 @@ async fn registration_creates_an_account_and_signs_in() {
530530 }
531531
532532 #[tokio::test]
533+async fn issues_open_create_and_view() {
534+ let (state, app) = app().await;
535+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
536+ let repo = state
537+ .store
538+ .create_repo(NewRepo {
539+ owner_id: ada.id,
540+ group_id: None,
541+ name: "proj".to_string(),
542+ path: "proj".to_string(),
543+ description: None,
544+ visibility: model::Visibility::Private,
545+ default_branch: "main".to_string(),
546+ })
547+ .await
548+ .unwrap();
549+
550+ let cookie = login(&app).await;
551+ let token = cookie
552+ .split("fabrica_csrf=")
553+ .nth(1)
554+ .and_then(|s| s.split(';').next())
555+ .unwrap()
556+ .to_string();
557+
558+ // The issues list renders for the owner with a New button.
559+ let req = Request::builder()
560+ .uri("/ada/proj/-/issues")
561+ .header(header::COOKIE, cookie.clone())
562+ .body(Body::empty())
563+ .unwrap();
564+ let res = app.clone().oneshot(req).await.unwrap();
565+ assert_eq!(res.status(), StatusCode::OK);
566+ assert!(body_string(res).await.contains("New issue"));
567+
568+ // Create an issue.
569+ let req = Request::builder()
570+ .method("POST")
571+ .uri(format!("/issue-new/{}", repo.id))
572+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
573+ .header(header::COOKIE, cookie.clone())
574+ .body(Body::from(format!(
575+ "title=First+bug&body=It+is+broken&_csrf={token}"
576+ )))
577+ .unwrap();
578+ let res = app.clone().oneshot(req).await.unwrap();
579+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
580+ assert_eq!(
581+ res.headers().get(header::LOCATION).unwrap(),
582+ "/ada/proj/-/issues/1"
583+ );
584+
585+ // View it.
586+ let req = Request::builder()
587+ .uri("/ada/proj/-/issues/1")
588+ .header(header::COOKIE, cookie)
589+ .body(Body::empty())
590+ .unwrap();
591+ let res = app.oneshot(req).await.unwrap();
592+ assert_eq!(res.status(), StatusCode::OK);
593+ let html = body_string(res).await;
594+ assert!(html.contains("First bug"), "issue title shown");
595+ assert!(html.contains("#1"), "issue number shown");
596+}
597+
598+#[tokio::test]
599+async fn issues_404_when_disabled() {
600+ let (state, app) = app().await;
601+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
602+ let repo = state
603+ .store
604+ .create_repo(NewRepo {
605+ owner_id: ada.id,
606+ group_id: None,
607+ name: "noissues".to_string(),
608+ path: "noissues".to_string(),
609+ description: None,
610+ visibility: model::Visibility::Public,
611+ default_branch: "main".to_string(),
612+ })
613+ .await
614+ .unwrap();
615+ state
616+ .store
617+ .set_feature_enabled(&repo.id, "issues", false)
618+ .await
619+ .unwrap();
620+ let res = app.oneshot(get("/ada/noissues/-/issues")).await.unwrap();
621+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
622+}
623+
624+#[tokio::test]
533625 async fn settings_requires_authentication() {
534626 let (_state, app) = app().await;
535627 let res = app.oneshot(get("/settings")).await.unwrap();