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 = [
5629 "tower-http",5629 "tower-http",
5630 "tracing",5630 "tracing",
5631 "tracing-subscriber",5631 "tracing-subscriber",
5632 "url",
5632]5633]
56335634
5634[[package]]5635[[package]]
assets/base.css +136 −3
@@ -322,6 +322,137 @@ body.drawer-open .drawer-backdrop {
322 flex: none;322 flex: none;
323}323}
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
325/* Repo settings danger zone. */456/* Repo settings danger zone. */
326.danger-zone {457.danger-zone {
327 border-color: var(--fb-danger);458 border-color: var(--fb-danger);
@@ -1682,13 +1813,15 @@ td.diff-empty {
1682 display: block;1813 display: block;
1683 overflow-x: auto;1814 overflow-x: auto;
1684 }1815 }
1685 /* Stack the profile and dashboard columns on narrow screens. */1816 /* Stack the profile, dashboard, and issue columns on narrow screens. */
1686 .profile-layout,1817 .profile-layout,
1687 .dashboard {1818 .dashboard,
1819 .issue-main {
1688 flex-direction: column;1820 flex-direction: column;
1689 }1821 }
1690 .profile-sidebar,1822 .profile-sidebar,
1691 .dashboard-side {1823 .dashboard-side,
1824 .issue-side {
1692 width: 100%;1825 width: 100%;
1693 }1826 }
1694}1827}
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
7use model::{Comment, Issue, IssueState, Label};
8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};
10
11use crate::{Store, StoreError, new_id, now_ms};
12
13/// The columns of `issues` in a fixed order.
14const 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)]
19pub struct StateCounts {
20 /// Number of open items.
21 pub open: i64,
22 /// Number of closed items.
23 pub closed: i64,
24}
25
26impl 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
28mod groups;28mod groups;
29mod invites;29mod invites;
30mod issues;
30mod keys;31mod keys;
31mod labels;32mod labels;
32mod repos;33mod repos;
@@ -43,6 +44,7 @@ use sqlx::any::{AnyPoolOptions, AnyRow};
43use sqlx::migrate::Migrator;44use sqlx::migrate::Migrator;
4445
45pub use crate::invites::NewInvite;46pub use crate::invites::NewInvite;
47pub use crate::issues::StateCounts;
46pub use crate::keys::NewKey;48pub use crate::keys::NewKey;
47pub use crate::repos::{Collaborator, NewRepo};49pub use crate::repos::{Collaborator, NewRepo};
48pub use crate::sessions::NewSession;50pub use crate::sessions::NewSession;
crates/web/Cargo.toml +1 −0
@@ -34,6 +34,7 @@ base64 = { workspace = true }
34time = { workspace = true }34time = { workspace = true }
35serde = { workspace = true }35serde = { workspace = true }
36serde_json = { workspace = true }36serde_json = { workspace = true }
37url = { workspace = true }
37thiserror = { workspace = true }38thiserror = { workspace = true }
38tokio = { workspace = true }39tokio = { workspace = true }
39tracing = { workspace = true }40tracing = { workspace = true }
crates/web/src/icons.rs +6 −0
@@ -17,6 +17,8 @@ pub enum Icon {
17 Folder,17 Folder,
18 File,18 File,
19 GitBranch,19 GitBranch,
20 Issue,
21 GitPull,
20 History,22 History,
21 ChevronDown,23 ChevronDown,
22 Search,24 Search,
@@ -58,6 +60,10 @@ impl Icon {
58 Icon::GitBranch => {60 Icon::GitBranch => {
59 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"/>"#61 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"/>"#
60 }62 }
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 }
61 Icon::History => {67 Icon::History => {
62 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"/>"#68 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"/>"#
63 }69 }
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
10use axum::Form;
11use axum::extract::{Path, Query, State};
12use axum::http::{HeaderMap, Uri};
13use axum::response::{IntoResponse, Redirect, Response};
14use axum_extra::extract::cookie::CookieJar;
15use maud::{Markup, html};
16use model::{Issue, IssueState, Label, User};
17use serde::Deserialize;
18
19use crate::AppState;
20use crate::error::{AppError, AppResult};
21use crate::icons::{Icon, icon};
22use crate::layout::page;
23use crate::markdown;
24use crate::pages::build_chrome;
25use crate::repo::{RepoCtx, Tab, css_color, label_chip, repo_header};
26use 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)]
31pub(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
42impl 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.
55pub(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.
139pub(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.
179pub(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).
274fn 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.
287fn 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.
298fn 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).
311fn 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).
326async 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.
349fn 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.
367async 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.
380async 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.
395async 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.
421async 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)]
429pub 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)]
443pub 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).
450pub 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)]
492pub 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.
502pub 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)]
536pub 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).
546pub 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)]
581pub 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).
591pub 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.
626pub 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.
682fn 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;
17mod error;17mod error;
18mod git_http;18mod git_http;
19mod icons;19mod icons;
20mod issues;
20mod layout;21mod layout;
21mod markdown;22mod markdown;
22mod pages;23mod pages;
@@ -176,6 +177,12 @@ pub fn build_router(state: AppState) -> Router {
176 "/repo-settings/{id}/labels/delete",177 "/repo-settings/{id}/labels/delete",
177 post(repo::settings_label_delete),178 post(repo::settings_label_delete),
178 )179 )
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))
179 .route("/healthz", get(serve_assets::healthz))186 .route("/healthz", get(serve_assets::healthz))
180 .route("/version", get(serve_assets::version))187 .route("/version", get(serve_assets::version))
181 .route("/theme.css", get(serve_assets::theme_default))188 .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
59/// Which repo tab is active, for highlighting.59/// Which repo tab is active, for highlighting.
60#[derive(Clone, Copy, PartialEq, Eq)]60#[derive(Clone, Copy, PartialEq, Eq)]
61enum Tab {61pub(crate) enum Tab {
62 Code,62 Code,
63 Issues,
64 Pulls,
63 Commits,65 Commits,
64 Branches,66 Branches,
65 Tags,67 Tags,
@@ -97,7 +99,7 @@ where
9799
98/// The local branch names for the switcher, empty on any error (an empty repo or100/// The local branch names for the switcher, empty on any error (an empty repo or
99/// a failed read simply yields no dropdown).101/// a failed read simply yields no dropdown).
100async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> {102pub(crate) async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> {
101 git_read(state, repo_id, git::Repo::branch_names)103 git_read(state, repo_id, git::Repo::branch_names)
102 .await104 .await
103 .unwrap_or_default()105 .unwrap_or_default()
@@ -108,7 +110,7 @@ async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> {
108///110///
109/// Denied access to an existing private repo returns `Err(NotFound)` — identical111/// Denied access to an existing private repo returns `Err(NotFound)` — identical
110/// to a missing repo, so private existence never leaks.112/// to a missing repo, so private existence never leaks.
111async fn resolve(113pub(crate) async fn resolve(
112 state: &AppState,114 state: &AppState,
113 owner_name: &str,115 owner_name: &str,
114 repo_path: &str,116 repo_path: &str,
@@ -289,6 +291,20 @@ async fn dispatch_view(
289 let raw_query = dq.q.clone().unwrap_or_default();291 let raw_query = dq.q.clone().unwrap_or_default();
290 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await292 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
291 }293 }
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 }
292 "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,308 "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,
293 // Reserved `runs` slot returns 404 until built.309 // Reserved `runs` slot returns 404 until built.
294 _ => Err(AppError::NotFound),310 _ => Err(AppError::NotFound),
@@ -839,7 +855,7 @@ fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collabora
839}855}
840856
841/// A coloured label chip.857/// A coloured label chip.
842fn label_chip(label: &model::Label) -> Markup {858pub(crate) fn label_chip(label: &model::Label) -> Markup {
843 html! {859 html! {
844 span class="label-chip" style=(format!("--label-color: {}", css_color(&label.color))) {860 span class="label-chip" style=(format!("--label-color: {}", css_color(&label.color))) {
845 (label.name)861 (label.name)
@@ -848,7 +864,7 @@ fn label_chip(label: &model::Label) -> Markup {
848}864}
849865
850/// Sanitize a stored colour for inline CSS: only `#` + up to 8 hex digits.866/// Sanitize a stored colour for inline CSS: only `#` + up to 8 hex digits.
851fn css_color(color: &str) -> String {867pub(crate) fn css_color(color: &str) -> String {
852 let hex: String = color868 let hex: String = color
853 .trim_start_matches('#')869 .trim_start_matches('#')
854 .chars()870 .chars()
@@ -1930,7 +1946,7 @@ fn link_icon(url: &str) -> Icon {
19301946
1931/// The repo sub-header: slug, description, and a tab strip sharing its line with1947/// The repo sub-header: slug, description, and a tab strip sharing its line with
1932/// the branch switcher and clone menu.1948/// the branch switcher and clone menu.
1933fn repo_header(1949pub(crate) fn repo_header(
1934 state: &AppState,1950 state: &AppState,
1935 ctx: &RepoCtx,1951 ctx: &RepoCtx,
1936 active: Tab,1952 active: Tab,
@@ -1958,6 +1974,12 @@ fn repo_header(
1958 div class="repo-nav" {1974 div class="repo-nav" {
1959 nav class="tabs repo-tabs" {1975 nav class="tabs repo-tabs" {
1960 (tab(Tab::Code, "Code", Icon::File, base.clone()))1976 (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 }
1961 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))1983 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))
1962 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))1984 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1963 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))1985 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
@@ -2273,7 +2295,7 @@ pub(crate) fn fmt_joined(ms: i64) -> String {
2273}2295}
22742296
2275/// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date.2297/// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date.
2276fn fmt_date(ms: i64) -> String {2298pub(crate) fn fmt_date(ms: i64) -> String {
2277 let secs = ms.div_euclid(1000);2299 let secs = ms.div_euclid(1000);
2278 match time::OffsetDateTime::from_unix_timestamp(secs) {2300 match time::OffsetDateTime::from_unix_timestamp(secs) {
2279 Ok(dt) => format!(2301 Ok(dt) => format!(
crates/web/src/tests.rs +92 −0
@@ -530,6 +530,98 @@ async fn registration_creates_an_account_and_signs_in() {
530}530}
531531
532#[tokio::test]532#[tokio::test]
533async 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]
599async 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]
533async fn settings_requires_authentication() {625async fn settings_requires_authentication() {
534 let (_state, app) = app().await;626 let (_state, app) = app().await;
535 let res = app.oneshot(get("/settings")).await.unwrap();627 let res = app.oneshot(get("/settings")).await.unwrap();