Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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 | ] |
| 5633 | 5634 | ||
| 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 | } |
| 324 | 324 | ||
| 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 | |||
| 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 @@ | |||
| 27 | 27 | ||
| 28 | mod groups; | 28 | mod groups; |
| 29 | mod invites; | 29 | mod invites; |
| 30 | mod issues; | ||
| 30 | mod keys; | 31 | mod keys; |
| 31 | mod labels; | 32 | mod labels; |
| 32 | mod repos; | 33 | mod repos; |
| @@ -43,6 +44,7 @@ use sqlx::any::{AnyPoolOptions, AnyRow}; | |||
| 43 | use sqlx::migrate::Migrator; | 44 | use sqlx::migrate::Migrator; |
| 44 | 45 | ||
| 45 | pub use crate::invites::NewInvite; | 46 | pub use crate::invites::NewInvite; |
| 47 | pub use crate::issues::StateCounts; | ||
| 46 | pub use crate::keys::NewKey; | 48 | pub use crate::keys::NewKey; |
| 47 | pub use crate::repos::{Collaborator, NewRepo}; | 49 | pub use crate::repos::{Collaborator, NewRepo}; |
| 48 | pub use crate::sessions::NewSession; | 50 | pub use crate::sessions::NewSession; |
crates/web/Cargo.toml +1 −0
| @@ -34,6 +34,7 @@ base64 = { workspace = true } | |||
| 34 | time = { workspace = true } | 34 | time = { workspace = true } |
| 35 | serde = { workspace = true } | 35 | serde = { workspace = true } |
| 36 | serde_json = { workspace = true } | 36 | serde_json = { workspace = true } |
| 37 | url = { workspace = true } | ||
| 37 | thiserror = { workspace = true } | 38 | thiserror = { workspace = true } |
| 38 | tokio = { workspace = true } | 39 | tokio = { workspace = true } |
| 39 | tracing = { workspace = true } | 40 | tracing = { 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 | |||
| 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; | |||
| 17 | mod error; | 17 | mod error; |
| 18 | mod git_http; | 18 | mod git_http; |
| 19 | mod icons; | 19 | mod icons; |
| 20 | mod issues; | ||
| 20 | mod layout; | 21 | mod layout; |
| 21 | mod markdown; | 22 | mod markdown; |
| 22 | mod pages; | 23 | mod 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" | |||
| 58 | 58 | ||
| 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)] |
| 61 | enum Tab { | 61 | pub(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 | |||
| 97 | 99 | ||
| 98 | /// The local branch names for the switcher, empty on any error (an empty repo or | 100 | /// 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). |
| 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> { |
| 101 | git_read(state, repo_id, git::Repo::branch_names) | 103 | git_read(state, repo_id, git::Repo::branch_names) |
| 102 | .await | 104 | .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)` — identical | 111 | /// 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. |
| 111 | async fn resolve( | 113 | pub(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).await | 292 | 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 | } |
| 840 | 856 | ||
| 841 | /// A coloured label chip. | 857 | /// A coloured label chip. |
| 842 | fn label_chip(label: &model::Label) -> Markup { | 858 | pub(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 | } |
| 849 | 865 | ||
| 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. |
| 851 | fn css_color(color: &str) -> String { | 867 | pub(crate) fn css_color(color: &str) -> String { |
| 852 | let hex: String = color | 868 | 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 { | |||
| 1930 | 1946 | ||
| 1931 | /// The repo sub-header: slug, description, and a tab strip sharing its line with | 1947 | /// 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. |
| 1933 | fn repo_header( | 1949 | pub(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 | } |
| 2274 | 2296 | ||
| 2275 | /// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date. | 2297 | /// 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 { |
| 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 | } |
| 531 | 531 | ||
| 532 | #[tokio::test] | 532 | #[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] | ||
| 533 | async fn settings_requires_authentication() { | 625 | async 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(); |