Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +1 −0
| @@ -5629,6 +5629,7 @@ dependencies = [ | |||
| 5629 | 5629 | "tower-http", | |
| 5630 | 5630 | "tracing", | |
| 5631 | 5631 | "tracing-subscriber", | |
| 5632 | + | "url", | |
| 5632 | 5633 | ] | |
| 5633 | 5634 | ||
| 5634 | 5635 | [[package]] | |
assets/base.css +136 −3
| @@ -322,6 +322,137 @@ body.drawer-open .drawer-backdrop { | |||
| 322 | 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 | 456 | /* Repo settings danger zone. */ | |
| 326 | 457 | .danger-zone { | |
| 327 | 458 | border-color: var(--fb-danger); | |
| @@ -1682,13 +1813,15 @@ td.diff-empty { | |||
| 1682 | 1813 | display: block; | |
| 1683 | 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 | 1817 | .profile-layout, | |
| 1687 | - | .dashboard { | |
| 1818 | + | .dashboard, | |
| 1819 | + | .issue-main { | |
| 1688 | 1820 | flex-direction: column; | |
| 1689 | 1821 | } | |
| 1690 | 1822 | .profile-sidebar, | |
| 1691 | - | .dashboard-side { | |
| 1823 | + | .dashboard-side, | |
| 1824 | + | .issue-side { | |
| 1692 | 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 | 28 | mod groups; | |
| 29 | 29 | mod invites; | |
| 30 | + | mod issues; | |
| 30 | 31 | mod keys; | |
| 31 | 32 | mod labels; | |
| 32 | 33 | mod repos; | |
| @@ -43,6 +44,7 @@ use sqlx::any::{AnyPoolOptions, AnyRow}; | |||
| 43 | 44 | use sqlx::migrate::Migrator; | |
| 44 | 45 | ||
| 45 | 46 | pub use crate::invites::NewInvite; | |
| 47 | + | pub use crate::issues::StateCounts; | |
| 46 | 48 | pub use crate::keys::NewKey; | |
| 47 | 49 | pub use crate::repos::{Collaborator, NewRepo}; | |
| 48 | 50 | pub use crate::sessions::NewSession; | |
crates/web/Cargo.toml +1 −0
| @@ -34,6 +34,7 @@ base64 = { workspace = true } | |||
| 34 | 34 | time = { workspace = true } | |
| 35 | 35 | serde = { workspace = true } | |
| 36 | 36 | serde_json = { workspace = true } | |
| 37 | + | url = { workspace = true } | |
| 37 | 38 | thiserror = { workspace = true } | |
| 38 | 39 | tokio = { workspace = true } | |
| 39 | 40 | tracing = { workspace = true } | |
crates/web/src/icons.rs +6 −0
| @@ -17,6 +17,8 @@ pub enum Icon { | |||
| 17 | 17 | Folder, | |
| 18 | 18 | File, | |
| 19 | 19 | GitBranch, | |
| 20 | + | Issue, | |
| 21 | + | GitPull, | |
| 20 | 22 | History, | |
| 21 | 23 | ChevronDown, | |
| 22 | 24 | Search, | |
| @@ -58,6 +60,10 @@ impl Icon { | |||
| 58 | 60 | Icon::GitBranch => { | |
| 59 | 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 | 67 | Icon::History => { | |
| 62 | 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 | 17 | mod error; | |
| 18 | 18 | mod git_http; | |
| 19 | 19 | mod icons; | |
| 20 | + | mod issues; | |
| 20 | 21 | mod layout; | |
| 21 | 22 | mod markdown; | |
| 22 | 23 | mod pages; | |
| @@ -176,6 +177,12 @@ pub fn build_router(state: AppState) -> Router { | |||
| 176 | 177 | "/repo-settings/{id}/labels/delete", | |
| 177 | 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 | 186 | .route("/healthz", get(serve_assets::healthz)) | |
| 180 | 187 | .route("/version", get(serve_assets::version)) | |
| 181 | 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 | 59 | /// Which repo tab is active, for highlighting. | |
| 60 | 60 | #[derive(Clone, Copy, PartialEq, Eq)] | |
| 61 | - | enum Tab { | |
| 61 | + | pub(crate) enum Tab { | |
| 62 | 62 | Code, | |
| 63 | + | Issues, | |
| 64 | + | Pulls, | |
| 63 | 65 | Commits, | |
| 64 | 66 | Branches, | |
| 65 | 67 | Tags, | |
| @@ -97,7 +99,7 @@ where | |||
| 97 | 99 | ||
| 98 | 100 | /// The local branch names for the switcher, empty on any error (an empty repo or | |
| 99 | 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 | 103 | git_read(state, repo_id, git::Repo::branch_names) | |
| 102 | 104 | .await | |
| 103 | 105 | .unwrap_or_default() | |
| @@ -108,7 +110,7 @@ async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> { | |||
| 108 | 110 | /// | |
| 109 | 111 | /// Denied access to an existing private repo returns `Err(NotFound)` — identical | |
| 110 | 112 | /// to a missing repo, so private existence never leaks. | |
| 111 | - | async fn resolve( | |
| 113 | + | pub(crate) async fn resolve( | |
| 112 | 114 | state: &AppState, | |
| 113 | 115 | owner_name: &str, | |
| 114 | 116 | repo_path: &str, | |
| @@ -289,6 +291,20 @@ async fn dispatch_view( | |||
| 289 | 291 | let raw_query = dq.q.clone().unwrap_or_default(); | |
| 290 | 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 | 308 | "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await, | |
| 293 | 309 | // Reserved `runs` slot returns 404 until built. | |
| 294 | 310 | _ => Err(AppError::NotFound), | |
| @@ -839,7 +855,7 @@ fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collabora | |||
| 839 | 855 | } | |
| 840 | 856 | ||
| 841 | 857 | /// A coloured label chip. | |
| 842 | - | fn label_chip(label: &model::Label) -> Markup { | |
| 858 | + | pub(crate) fn label_chip(label: &model::Label) -> Markup { | |
| 843 | 859 | html! { | |
| 844 | 860 | span class="label-chip" style=(format!("--label-color: {}", css_color(&label.color))) { | |
| 845 | 861 | (label.name) | |
| @@ -848,7 +864,7 @@ fn label_chip(label: &model::Label) -> Markup { | |||
| 848 | 864 | } | |
| 849 | 865 | ||
| 850 | 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 | 868 | let hex: String = color | |
| 853 | 869 | .trim_start_matches('#') | |
| 854 | 870 | .chars() | |
| @@ -1930,7 +1946,7 @@ fn link_icon(url: &str) -> Icon { | |||
| 1930 | 1946 | ||
| 1931 | 1947 | /// The repo sub-header: slug, description, and a tab strip sharing its line with | |
| 1932 | 1948 | /// the branch switcher and clone menu. | |
| 1933 | - | fn repo_header( | |
| 1949 | + | pub(crate) fn repo_header( | |
| 1934 | 1950 | state: &AppState, | |
| 1935 | 1951 | ctx: &RepoCtx, | |
| 1936 | 1952 | active: Tab, | |
| @@ -1958,6 +1974,12 @@ fn repo_header( | |||
| 1958 | 1974 | div class="repo-nav" { | |
| 1959 | 1975 | nav class="tabs repo-tabs" { | |
| 1960 | 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 | 1983 | (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}"))) | |
| 1962 | 1984 | (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches"))) | |
| 1963 | 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 | 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 | 2299 | let secs = ms.div_euclid(1000); | |
| 2278 | 2300 | match time::OffsetDateTime::from_unix_timestamp(secs) { | |
| 2279 | 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 | 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 | 625 | async fn settings_requires_authentication() { | |
| 534 | 626 | let (_state, app) = app().await; | |
| 535 | 627 | let res = app.oneshot(get("/settings")).await.unwrap(); | |