Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +39 −0
| @@ -514,6 +514,42 @@ body.drawer-open .drawer-backdrop { | |||
| 514 | .issue-header h1 { | 514 | .issue-header h1 { |
| 515 | margin: 0; | 515 | margin: 0; |
| 516 | } | 516 | } |
| 517 | .issue-title-line { | ||
| 518 | display: flex; | ||
| 519 | align-items: flex-start; | ||
| 520 | justify-content: space-between; | ||
| 521 | gap: 1rem; | ||
| 522 | } | ||
| 523 | .issue-edit, | ||
| 524 | .comment-edit { | ||
| 525 | flex: none; | ||
| 526 | } | ||
| 527 | .issue-edit > summary, | ||
| 528 | .comment-edit > summary { | ||
| 529 | cursor: pointer; | ||
| 530 | list-style: none; | ||
| 531 | } | ||
| 532 | .issue-edit > summary::-webkit-details-marker, | ||
| 533 | .comment-edit > summary::-webkit-details-marker { | ||
| 534 | display: none; | ||
| 535 | } | ||
| 536 | .issue-edit .card { | ||
| 537 | margin-top: 0.5rem; | ||
| 538 | width: min(32rem, 90vw); | ||
| 539 | } | ||
| 540 | .comment-edit { | ||
| 541 | margin-left: auto; | ||
| 542 | } | ||
| 543 | .comment-edit > summary { | ||
| 544 | font-size: 0.9em; | ||
| 545 | color: var(--fb-accent); | ||
| 546 | } | ||
| 547 | .comment-edit form { | ||
| 548 | margin-top: 0.5rem; | ||
| 549 | } | ||
| 550 | .comment-edit textarea { | ||
| 551 | font-family: inherit; | ||
| 552 | } | ||
| 517 | .issue-sub { | 553 | .issue-sub { |
| 518 | display: flex; | 554 | display: flex; |
| 519 | align-items: center; | 555 | align-items: center; |
| @@ -549,6 +585,9 @@ body.drawer-open .drawer-backdrop { | |||
| 549 | overflow: hidden; | 585 | overflow: hidden; |
| 550 | } | 586 | } |
| 551 | .comment-head { | 587 | .comment-head { |
| 588 | display: flex; | ||
| 589 | align-items: center; | ||
| 590 | gap: 0.35rem; | ||
| 552 | padding: 0.5rem 0.85rem; | 591 | padding: 0.5rem 0.85rem; |
| 553 | border-bottom: 1px solid var(--fb-border); | 592 | border-bottom: 1px solid var(--fb-border); |
| 554 | font-size: 0.9em; | 593 | font-size: 0.9em; |
crates/model/src/entity.rs +10 −0
| @@ -350,6 +350,16 @@ pub struct Issue { | |||
| 350 | pub updated_at: Timestamp, | 350 | pub updated_at: Timestamp, |
| 351 | /// When it was closed, if it is. | 351 | /// When it was closed, if it is. |
| 352 | pub closed_at: Option<Timestamp>, | 352 | pub closed_at: Option<Timestamp>, |
| 353 | /// When the conversation was locked, or `None` if unlocked. | ||
| 354 | pub locked_at: Option<Timestamp>, | ||
| 355 | } | ||
| 356 | |||
| 357 | impl Issue { | ||
| 358 | /// Whether the conversation is locked (only writers may comment). | ||
| 359 | #[must_use] | ||
| 360 | pub fn locked(&self) -> bool { | ||
| 361 | self.locked_at.is_some() | ||
| 362 | } | ||
| 353 | } | 363 | } |
| 354 | 364 | ||
| 355 | /// A comment on an issue or pull request. | 365 | /// A comment on an issue or pull request. |
crates/store/src/issues.rs +68 −3
| @@ -12,7 +12,7 @@ use crate::{Store, StoreError, new_id, now_ms}; | |||
| 12 | 12 | ||
| 13 | /// The columns of `issues` in a fixed order. | 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, \ | 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"; | 15 | assignee_id, created_at, updated_at, closed_at, locked_at"; |
| 16 | 16 | ||
| 17 | /// Open/closed counts for a repo's issues or PRs. | 17 | /// Open/closed counts for a repo's issues or PRs. |
| 18 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | 18 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| @@ -64,6 +64,7 @@ impl Store { | |||
| 64 | created_at: now, | 64 | created_at: now, |
| 65 | updated_at: now, | 65 | updated_at: now, |
| 66 | closed_at: None, | 66 | closed_at: None, |
| 67 | locked_at: None, | ||
| 67 | }; | 68 | }; |
| 68 | let sql = "INSERT INTO issues \ | 69 | let sql = "INSERT INTO issues \ |
| 69 | (id, repo_id, number, author_id, title, body, state, is_pull, assignee_id, \ | 70 | (id, repo_id, number, author_id, title, body, state, is_pull, assignee_id, \ |
| @@ -231,6 +232,25 @@ impl Store { | |||
| 231 | Ok(updated > 0) | 232 | Ok(updated > 0) |
| 232 | } | 233 | } |
| 233 | 234 | ||
| 235 | /// Lock or unlock an issue/PR conversation. | ||
| 236 | /// | ||
| 237 | /// # Errors | ||
| 238 | /// | ||
| 239 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 240 | pub async fn set_issue_locked(&self, id: &str, locked: bool) -> Result<bool, StoreError> { | ||
| 241 | let now = now_ms(); | ||
| 242 | let locked_at = locked.then_some(now); | ||
| 243 | let updated = | ||
| 244 | sqlx::query("UPDATE issues SET locked_at = $1, updated_at = $2 WHERE id = $3") | ||
| 245 | .bind(locked_at) | ||
| 246 | .bind(now) | ||
| 247 | .bind(id) | ||
| 248 | .execute(&self.pool) | ||
| 249 | .await? | ||
| 250 | .rows_affected(); | ||
| 251 | Ok(updated > 0) | ||
| 252 | } | ||
| 253 | |||
| 234 | /// Set (or clear) an issue/PR's assignee. | 254 | /// Set (or clear) an issue/PR's assignee. |
| 235 | /// | 255 | /// |
| 236 | /// # Errors | 256 | /// # Errors |
| @@ -346,6 +366,49 @@ impl Store { | |||
| 346 | .map_err(Into::into) | 366 | .map_err(Into::into) |
| 347 | } | 367 | } |
| 348 | 368 | ||
| 369 | /// Fetch a single comment by id (for edit authorization). | ||
| 370 | /// | ||
| 371 | /// # Errors | ||
| 372 | /// | ||
| 373 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 374 | pub async fn comment_by_id(&self, id: &str) -> Result<Option<Comment>, StoreError> { | ||
| 375 | let row = sqlx::query( | ||
| 376 | "SELECT id, issue_id, author_id, body, created_at, updated_at FROM comments \ | ||
| 377 | WHERE id = $1", | ||
| 378 | ) | ||
| 379 | .bind(id) | ||
| 380 | .fetch_optional(&self.pool) | ||
| 381 | .await?; | ||
| 382 | row.map(|r| { | ||
| 383 | Ok(Comment { | ||
| 384 | id: r.try_get("id")?, | ||
| 385 | issue_id: r.try_get("issue_id")?, | ||
| 386 | author_id: r.try_get("author_id")?, | ||
| 387 | body: r.try_get("body")?, | ||
| 388 | created_at: r.try_get("created_at")?, | ||
| 389 | updated_at: r.try_get("updated_at")?, | ||
| 390 | }) | ||
| 391 | }) | ||
| 392 | .transpose() | ||
| 393 | .map_err(|e: sqlx::Error| e.into()) | ||
| 394 | } | ||
| 395 | |||
| 396 | /// Edit a comment's body. | ||
| 397 | /// | ||
| 398 | /// # Errors | ||
| 399 | /// | ||
| 400 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 401 | pub async fn update_comment(&self, id: &str, body: &str) -> Result<bool, StoreError> { | ||
| 402 | let updated = sqlx::query("UPDATE comments SET body = $1, updated_at = $2 WHERE id = $3") | ||
| 403 | .bind(body) | ||
| 404 | .bind(now_ms()) | ||
| 405 | .bind(id) | ||
| 406 | .execute(&self.pool) | ||
| 407 | .await? | ||
| 408 | .rows_affected(); | ||
| 409 | Ok(updated > 0) | ||
| 410 | } | ||
| 411 | |||
| 349 | // ---- Issue labels ---- | 412 | // ---- Issue labels ---- |
| 350 | 413 | ||
| 351 | /// The labels applied to an issue/PR. | 414 | /// The labels applied to an issue/PR. |
| @@ -454,6 +517,7 @@ impl Store { | |||
| 454 | created_at: now, | 517 | created_at: now, |
| 455 | updated_at: now, | 518 | updated_at: now, |
| 456 | closed_at: None, | 519 | closed_at: None, |
| 520 | locked_at: None, | ||
| 457 | }; | 521 | }; |
| 458 | sqlx::query( | 522 | sqlx::query( |
| 459 | "INSERT INTO issues \ | 523 | "INSERT INTO issues \ |
| @@ -600,7 +664,7 @@ impl Store { | |||
| 600 | pub async fn dependencies_of(&self, issue_id: &str) -> Result<Vec<Issue>, StoreError> { | 664 | pub async fn dependencies_of(&self, issue_id: &str) -> Result<Vec<Issue>, StoreError> { |
| 601 | self.related_issues( | 665 | self.related_issues( |
| 602 | "SELECT i.id, i.repo_id, i.number, i.author_id, i.title, i.body, i.state, \ | 666 | "SELECT i.id, i.repo_id, i.number, i.author_id, i.title, i.body, i.state, \ |
| 603 | i.is_pull, i.assignee_id, i.created_at, i.updated_at, i.closed_at \ | 667 | i.is_pull, i.assignee_id, i.created_at, i.updated_at, i.closed_at, i.locked_at \ |
| 604 | FROM issue_dependencies d JOIN issues i ON i.id = d.depends_on_id \ | 668 | FROM issue_dependencies d JOIN issues i ON i.id = d.depends_on_id \ |
| 605 | WHERE d.issue_id = $1 ORDER BY i.number ASC", | 669 | WHERE d.issue_id = $1 ORDER BY i.number ASC", |
| 606 | issue_id, | 670 | issue_id, |
| @@ -616,7 +680,7 @@ impl Store { | |||
| 616 | pub async fn dependents_of(&self, issue_id: &str) -> Result<Vec<Issue>, StoreError> { | 680 | pub async fn dependents_of(&self, issue_id: &str) -> Result<Vec<Issue>, StoreError> { |
| 617 | self.related_issues( | 681 | self.related_issues( |
| 618 | "SELECT i.id, i.repo_id, i.number, i.author_id, i.title, i.body, i.state, \ | 682 | "SELECT i.id, i.repo_id, i.number, i.author_id, i.title, i.body, i.state, \ |
| 619 | i.is_pull, i.assignee_id, i.created_at, i.updated_at, i.closed_at \ | 683 | i.is_pull, i.assignee_id, i.created_at, i.updated_at, i.closed_at, i.locked_at \ |
| 620 | FROM issue_dependencies d JOIN issues i ON i.id = d.issue_id \ | 684 | FROM issue_dependencies d JOIN issues i ON i.id = d.issue_id \ |
| 621 | WHERE d.depends_on_id = $1 ORDER BY i.number ASC", | 685 | WHERE d.depends_on_id = $1 ORDER BY i.number ASC", |
| 622 | issue_id, | 686 | issue_id, |
| @@ -667,6 +731,7 @@ impl Store { | |||
| 667 | created_at: row.try_get("created_at")?, | 731 | created_at: row.try_get("created_at")?, |
| 668 | updated_at: row.try_get("updated_at")?, | 732 | updated_at: row.try_get("updated_at")?, |
| 669 | closed_at: row.try_get("closed_at")?, | 733 | closed_at: row.try_get("closed_at")?, |
| 734 | locked_at: row.try_get("locked_at")?, | ||
| 670 | }) | 735 | }) |
| 671 | } | 736 | } |
| 672 | } | 737 | } |
crates/web/src/issues.rs +240 −21
| @@ -230,6 +230,7 @@ pub(crate) async fn view( | |||
| 230 | 230 | ||
| 231 | let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; | 231 | let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; |
| 232 | let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id); | 232 | let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id); |
| 233 | let viewer_id: Option<String> = viewer.as_ref().map(|u| u.id.clone()); | ||
| 233 | let branches = crate::repo::branch_names(state, &ctx.repo.id).await; | 234 | let branches = crate::repo::branch_names(state, &ctx.repo.id).await; |
| 234 | 235 | ||
| 235 | let mut comment_rows = Vec::with_capacity(comments.len()); | 236 | let mut comment_rows = Vec::with_capacity(comments.len()); |
| @@ -243,7 +244,12 @@ pub(crate) async fn view( | |||
| 243 | (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches)) | 244 | (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches)) |
| 244 | div class="issue-view" { | 245 | div class="issue-view" { |
| 245 | header class="issue-header" { | 246 | header class="issue-header" { |
| 246 | h1 { (issue.title) " " span class="muted" { "#" (issue.number) } } | 247 | div class="issue-title-line" { |
| 248 | h1 { (issue.title) " " span class="muted" { "#" (issue.number) } } | ||
| 249 | @if can_write || is_author { | ||
| 250 | (edit_issue_form(&issue, kind, &csrf)) | ||
| 251 | } | ||
| 252 | } | ||
| 247 | div class="issue-sub" { | 253 | div class="issue-sub" { |
| 248 | (state_badge(issue.state, kind.is_pull)) | 254 | (state_badge(issue.state, kind.is_pull)) |
| 249 | span class="muted" { " " (author) " opened this " (kind.noun) } | 255 | span class="muted" { " " (author) " opened this " (kind.noun) } |
| @@ -251,33 +257,26 @@ pub(crate) async fn view( | |||
| 251 | } | 257 | } |
| 252 | div class="issue-main" { | 258 | div class="issue-main" { |
| 253 | article class="issue-thread" { | 259 | article class="issue-thread" { |
| 254 | (comment_card(&author, &issue.body, issue.created_at)) | 260 | (comment_card(&author, &issue.body, issue.created_at, None)) |
| 255 | @for (c, cauthor) in &comment_rows { | 261 | @for (c, cauthor) in &comment_rows { |
| 256 | (comment_card(cauthor, &c.body, c.created_at)) | 262 | @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str()); |
| 263 | (comment_card(cauthor, &c.body, c.created_at, | ||
| 264 | editable.then_some((c.id.as_str(), csrf.as_str())))) | ||
| 257 | } | 265 | } |
| 258 | @if can_write || is_author { | 266 | @if issue.locked() { (locked_note(can_write)) } |
| 259 | @let comment_form = format!("comment-{}", issue.id); | 267 | @if can_write || (is_author && !issue.locked()) { |
| 260 | div class="card comment-form" { | 268 | (comment_box(&issue, &csrf, kind, can_write)) |
| 261 | // The comment form holds only the textarea; the Comment | ||
| 262 | // button and the (separate) state form are siblings in | ||
| 263 | // the actions row — never nested forms. | ||
| 264 | form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) { | ||
| 265 | input type="hidden" name="_csrf" value=(csrf); | ||
| 266 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} | ||
| 267 | } | ||
| 268 | div class="comment-actions" { | ||
| 269 | (state_button(&issue, &csrf, kind)) | ||
| 270 | button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" } | ||
| 271 | } | ||
| 272 | } | ||
| 273 | } | 269 | } |
| 274 | } | 270 | } |
| 275 | aside class="issue-side" { | 271 | aside class="issue-side" { |
| 276 | section { | 272 | section { |
| 277 | h3 { "Assignee" } | 273 | h3 { "Assignee" } |
| 278 | @if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } } | ||
| 279 | @if can_write { | 274 | @if can_write { |
| 280 | (assignee_form(&issue, &ctx, state, &csrf).await) | 275 | (assignee_form(&issue, &ctx, state, &csrf).await) |
| 276 | } @else if let Some(a) = &assignee { | ||
| 277 | p { (a) } | ||
| 278 | } @else { | ||
| 279 | p class="muted" { "No one" } | ||
| 281 | } | 280 | } |
| 282 | } | 281 | } |
| 283 | section { | 282 | section { |
| @@ -299,13 +298,29 @@ pub(crate) async fn view( | |||
| 299 | 298 | ||
| 300 | // ---- shared render helpers ---- | 299 | // ---- shared render helpers ---- |
| 301 | 300 | ||
| 302 | /// A single comment card (also used for the issue body). | 301 | /// A single comment card (also used for the issue body). When `edit` is |
| 303 | pub(crate) fn comment_card(author: &str, body: &str, created_at: i64) -> Markup { | 302 | /// `Some((comment_id, csrf))`, an inline edit form is offered to the viewer. |
| 303 | pub(crate) fn comment_card( | ||
| 304 | author: &str, | ||
| 305 | body: &str, | ||
| 306 | created_at: i64, | ||
| 307 | edit: Option<(&str, &str)>, | ||
| 308 | ) -> Markup { | ||
| 304 | html! { | 309 | html! { |
| 305 | div class="card comment" { | 310 | div class="card comment" { |
| 306 | div class="comment-head muted" { | 311 | div class="comment-head muted" { |
| 307 | strong { (author) } | 312 | strong { (author) } |
| 308 | span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) } | 313 | span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) } |
| 314 | @if let Some((id, csrf)) = edit { | ||
| 315 | details class="comment-edit" { | ||
| 316 | summary { "Edit" } | ||
| 317 | form method="post" action=(format!("/comment/{id}/edit")) { | ||
| 318 | input type="hidden" name="_csrf" value=(csrf); | ||
| 319 | textarea name="body" rows="4" required { (body) } | ||
| 320 | button class="btn inline-btn" type="submit" { "Save" } | ||
| 321 | } | ||
| 322 | } | ||
| 323 | } | ||
| 309 | } | 324 | } |
| 310 | div class="comment-body markdown" { (markdown::render(body)) } | 325 | div class="comment-body markdown" { (markdown::render(body)) } |
| 311 | } | 326 | } |
| @@ -351,6 +366,70 @@ pub(crate) fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup { | |||
| 351 | } | 366 | } |
| 352 | } | 367 | } |
| 353 | 368 | ||
| 369 | /// The "conversation is locked" notice. | ||
| 370 | pub(crate) fn locked_note(can_write: bool) -> Markup { | ||
| 371 | html! { | ||
| 372 | div class="card locked-note muted" { | ||
| 373 | "🔒 This conversation is locked." | ||
| 374 | @if can_write { " Only those with write access can comment." } | ||
| 375 | } | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | /// The comment box: a textarea plus the state/lock/comment actions. The Comment | ||
| 380 | /// button references the form via `form=` so no forms are nested. | ||
| 381 | pub(crate) fn comment_box(issue: &Issue, csrf: &str, kind: Kind, can_write: bool) -> Markup { | ||
| 382 | let comment_form = format!("comment-{}", issue.id); | ||
| 383 | html! { | ||
| 384 | div class="card comment-form" { | ||
| 385 | form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) { | ||
| 386 | input type="hidden" name="_csrf" value=(csrf); | ||
| 387 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} | ||
| 388 | } | ||
| 389 | div class="comment-actions" { | ||
| 390 | (state_button(issue, csrf, kind)) | ||
| 391 | @if can_write { (lock_button(issue, csrf)) } | ||
| 392 | button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" } | ||
| 393 | } | ||
| 394 | } | ||
| 395 | } | ||
| 396 | } | ||
| 397 | |||
| 398 | /// The lock/unlock button (posts to the lock endpoint). Writers only. | ||
| 399 | pub(crate) fn lock_button(issue: &Issue, csrf: &str) -> Markup { | ||
| 400 | let (target, label) = if issue.locked() { | ||
| 401 | ("false", "Unlock") | ||
| 402 | } else { | ||
| 403 | ("true", "Lock") | ||
| 404 | }; | ||
| 405 | html! { | ||
| 406 | form method="post" action=(format!("/issue/{}/lock", issue.id)) class="inline-form" { | ||
| 407 | input type="hidden" name="_csrf" value=(csrf); | ||
| 408 | input type="hidden" name="locked" value=(target); | ||
| 409 | button class="btn inline-btn" type="submit" { (label) } | ||
| 410 | } | ||
| 411 | } | ||
| 412 | } | ||
| 413 | |||
| 414 | /// The edit-title/description form, revealed by a toggle in the issue header. | ||
| 415 | pub(crate) fn edit_issue_form(issue: &Issue, kind: Kind, csrf: &str) -> Markup { | ||
| 416 | html! { | ||
| 417 | details class="issue-edit" { | ||
| 418 | summary class="btn" { "Edit" } | ||
| 419 | div class="card" { | ||
| 420 | form method="post" action=(format!("/issue/{}/edit", issue.id)) { | ||
| 421 | input type="hidden" name="_csrf" value=(csrf); | ||
| 422 | label for="edit-title" { "Title" } | ||
| 423 | input type="text" id="edit-title" name="title" value=(issue.title) required; | ||
| 424 | label for="edit-body" { "Description" } | ||
| 425 | textarea id="edit-body" name="body" rows="6" { (issue.body) } | ||
| 426 | button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) } | ||
| 427 | } | ||
| 428 | } | ||
| 429 | } | ||
| 430 | } | ||
| 431 | } | ||
| 432 | |||
| 354 | /// The assignee select form (owner + collaborators). | 433 | /// The assignee select form (owner + collaborators). |
| 355 | pub(crate) async fn assignee_form( | 434 | pub(crate) async fn assignee_form( |
| 356 | issue: &Issue, | 435 | issue: &Issue, |
| @@ -613,6 +692,96 @@ pub async fn create( | |||
| 613 | respond_redirect(result) | 692 | respond_redirect(result) |
| 614 | } | 693 | } |
| 615 | 694 | ||
| 695 | /// Edit-issue form (title + description). | ||
| 696 | #[derive(Debug, Deserialize)] | ||
| 697 | pub struct EditIssueForm { | ||
| 698 | /// New title. | ||
| 699 | #[serde(default)] | ||
| 700 | title: String, | ||
| 701 | /// New markdown description. | ||
| 702 | #[serde(default)] | ||
| 703 | body: String, | ||
| 704 | /// CSRF token. | ||
| 705 | #[serde(rename = "_csrf")] | ||
| 706 | csrf: String, | ||
| 707 | } | ||
| 708 | |||
| 709 | /// `POST /issue/{id}/edit` — edit an issue/PR's title and description (Write or | ||
| 710 | /// the author). | ||
| 711 | pub async fn edit( | ||
| 712 | State(state): State<AppState>, | ||
| 713 | MaybeUser(viewer): MaybeUser, | ||
| 714 | jar: CookieJar, | ||
| 715 | headers: HeaderMap, | ||
| 716 | Path(id): Path<String>, | ||
| 717 | Form(form): Form<EditIssueForm>, | ||
| 718 | ) -> Response { | ||
| 719 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 720 | return AppError::Forbidden.into_response(); | ||
| 721 | } | ||
| 722 | let result = async { | ||
| 723 | let user = viewer.ok_or(AppError::Forbidden)?; | ||
| 724 | let issue = state | ||
| 725 | .store | ||
| 726 | .issue_by_id(&id) | ||
| 727 | .await? | ||
| 728 | .ok_or(AppError::NotFound)?; | ||
| 729 | let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; | ||
| 730 | if access < auth::Access::Write && user.id != issue.author_id { | ||
| 731 | return Err(AppError::Forbidden); | ||
| 732 | } | ||
| 733 | let title = form.title.trim(); | ||
| 734 | if title.is_empty() { | ||
| 735 | return Err(AppError::BadRequest("A title is required.".to_string())); | ||
| 736 | } | ||
| 737 | state | ||
| 738 | .store | ||
| 739 | .update_issue(&issue.id, title, form.body.trim()) | ||
| 740 | .await?; | ||
| 741 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | ||
| 742 | } | ||
| 743 | .await; | ||
| 744 | respond_redirect(result) | ||
| 745 | } | ||
| 746 | |||
| 747 | /// `POST /comment/{id}/edit` — edit a comment (Write or the comment's author). | ||
| 748 | pub async fn comment_edit( | ||
| 749 | State(state): State<AppState>, | ||
| 750 | MaybeUser(viewer): MaybeUser, | ||
| 751 | jar: CookieJar, | ||
| 752 | headers: HeaderMap, | ||
| 753 | Path(id): Path<String>, | ||
| 754 | Form(form): Form<CommentForm>, | ||
| 755 | ) -> Response { | ||
| 756 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 757 | return AppError::Forbidden.into_response(); | ||
| 758 | } | ||
| 759 | let result = async { | ||
| 760 | let user = viewer.ok_or(AppError::Forbidden)?; | ||
| 761 | let comment = state | ||
| 762 | .store | ||
| 763 | .comment_by_id(&id) | ||
| 764 | .await? | ||
| 765 | .ok_or(AppError::NotFound)?; | ||
| 766 | let issue = state | ||
| 767 | .store | ||
| 768 | .issue_by_id(&comment.issue_id) | ||
| 769 | .await? | ||
| 770 | .ok_or(AppError::NotFound)?; | ||
| 771 | let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; | ||
| 772 | if access < auth::Access::Write && user.id != comment.author_id { | ||
| 773 | return Err(AppError::Forbidden); | ||
| 774 | } | ||
| 775 | let body = form.body.trim(); | ||
| 776 | if !body.is_empty() { | ||
| 777 | state.store.update_comment(&comment.id, body).await?; | ||
| 778 | } | ||
| 779 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | ||
| 780 | } | ||
| 781 | .await; | ||
| 782 | respond_redirect(result) | ||
| 783 | } | ||
| 784 | |||
| 616 | /// Comment form. | 785 | /// Comment form. |
| 617 | #[derive(Debug, Deserialize)] | 786 | #[derive(Debug, Deserialize)] |
| 618 | pub struct CommentForm { | 787 | pub struct CommentForm { |
| @@ -647,6 +816,12 @@ pub async fn comment( | |||
| 647 | if access < auth::Access::Read { | 816 | if access < auth::Access::Read { |
| 648 | return Err(AppError::NotFound); | 817 | return Err(AppError::NotFound); |
| 649 | } | 818 | } |
| 819 | // A locked conversation only accepts comments from writers. | ||
| 820 | if issue.locked() && access < auth::Access::Write { | ||
| 821 | return Err(AppError::BadRequest( | ||
| 822 | "This conversation is locked.".to_string(), | ||
| 823 | )); | ||
| 824 | } | ||
| 650 | let body = form.body.trim(); | 825 | let body = form.body.trim(); |
| 651 | if !body.is_empty() { | 826 | if !body.is_empty() { |
| 652 | state.store.add_comment(&issue.id, &user.id, body).await?; | 827 | state.store.add_comment(&issue.id, &user.id, body).await?; |
| @@ -657,6 +832,50 @@ pub async fn comment( | |||
| 657 | respond_redirect(result) | 832 | respond_redirect(result) |
| 658 | } | 833 | } |
| 659 | 834 | ||
| 835 | /// Lock/unlock form. | ||
| 836 | #[derive(Debug, Deserialize)] | ||
| 837 | pub struct LockForm { | ||
| 838 | /// `true` to lock, else unlock. | ||
| 839 | #[serde(default)] | ||
| 840 | locked: String, | ||
| 841 | /// CSRF token. | ||
| 842 | #[serde(rename = "_csrf")] | ||
| 843 | csrf: String, | ||
| 844 | } | ||
| 845 | |||
| 846 | /// `POST /issue/{id}/lock` — lock or unlock the conversation (Write only). | ||
| 847 | pub async fn set_lock( | ||
| 848 | State(state): State<AppState>, | ||
| 849 | MaybeUser(viewer): MaybeUser, | ||
| 850 | jar: CookieJar, | ||
| 851 | headers: HeaderMap, | ||
| 852 | Path(id): Path<String>, | ||
| 853 | Form(form): Form<LockForm>, | ||
| 854 | ) -> Response { | ||
| 855 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 856 | return AppError::Forbidden.into_response(); | ||
| 857 | } | ||
| 858 | let result = async { | ||
| 859 | let user = viewer.ok_or(AppError::Forbidden)?; | ||
| 860 | let issue = state | ||
| 861 | .store | ||
| 862 | .issue_by_id(&id) | ||
| 863 | .await? | ||
| 864 | .ok_or(AppError::NotFound)?; | ||
| 865 | let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; | ||
| 866 | if access < auth::Access::Write { | ||
| 867 | return Err(AppError::Forbidden); | ||
| 868 | } | ||
| 869 | state | ||
| 870 | .store | ||
| 871 | .set_issue_locked(&issue.id, form.locked == "true") | ||
| 872 | .await?; | ||
| 873 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | ||
| 874 | } | ||
| 875 | .await; | ||
| 876 | respond_redirect(result) | ||
| 877 | } | ||
| 878 | |||
| 660 | /// State-change form. | 879 | /// State-change form. |
| 661 | #[derive(Debug, Deserialize)] | 880 | #[derive(Debug, Deserialize)] |
| 662 | pub struct StateForm { | 881 | pub struct StateForm { |
crates/web/src/lib.rs +4 −0
| @@ -120,6 +120,7 @@ pub(crate) fn now_ms() -> i64 { | |||
| 120 | } | 120 | } |
| 121 | 121 | ||
| 122 | /// Build the application router with all middleware wired. | 122 | /// Build the application router with all middleware wired. |
| 123 | #[allow(clippy::too_many_lines)] // A flat route table; splitting it hurts readability. | ||
| 123 | pub fn build_router(state: AppState) -> Router { | 124 | pub fn build_router(state: AppState) -> Router { |
| 124 | let x_request_id = HeaderName::from_static("x-request-id"); | 125 | let x_request_id = HeaderName::from_static("x-request-id"); |
| 125 | 126 | ||
| @@ -203,6 +204,9 @@ pub fn build_router(state: AppState) -> Router { | |||
| 203 | .route("/issue/{id}/labels", post(issues::set_labels)) | 204 | .route("/issue/{id}/labels", post(issues::set_labels)) |
| 204 | .route("/issue/{id}/deps/add", post(issues::deps_add)) | 205 | .route("/issue/{id}/deps/add", post(issues::deps_add)) |
| 205 | .route("/issue/{id}/deps/remove", post(issues::deps_remove)) | 206 | .route("/issue/{id}/deps/remove", post(issues::deps_remove)) |
| 207 | .route("/issue/{id}/edit", post(issues::edit)) | ||
| 208 | .route("/issue/{id}/lock", post(issues::set_lock)) | ||
| 209 | .route("/comment/{id}/edit", post(issues::comment_edit)) | ||
| 206 | .route("/pull-new/{repo_id}", post(pulls::create)) | 210 | .route("/pull-new/{repo_id}", post(pulls::create)) |
| 207 | .route("/pull/{id}/merge", post(pulls::merge)) | 211 | .route("/pull/{id}/merge", post(pulls::merge)) |
| 208 | .route("/healthz", get(serve_assets::healthz)) | 212 | .route("/healthz", get(serve_assets::healthz)) |
crates/web/src/pulls.rs +23 −19
| @@ -25,8 +25,8 @@ use crate::AppState; | |||
| 25 | use crate::diff; | 25 | use crate::diff; |
| 26 | use crate::error::{AppError, AppResult}; | 26 | use crate::error::{AppError, AppResult}; |
| 27 | use crate::issues::{ | 27 | use crate::issues::{ |
| 28 | Kind, access_for, assignee_form, comment_card, deps_section, issue_url, label_form, | 28 | Kind, access_for, assignee_form, comment_box, comment_card, deps_section, edit_issue_form, |
| 29 | repo_of_issue, respond_redirect, state_badge, state_button, username, | 29 | issue_url, label_form, locked_note, repo_of_issue, respond_redirect, state_badge, username, |
| 30 | }; | 30 | }; |
| 31 | use crate::layout::page; | 31 | use crate::layout::page; |
| 32 | use crate::pages::build_chrome; | 32 | use crate::pages::build_chrome; |
| @@ -117,6 +117,7 @@ pub(crate) async fn view( | |||
| 117 | 117 | ||
| 118 | let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; | 118 | let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; |
| 119 | let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id); | 119 | let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id); |
| 120 | let viewer_id: Option<String> = viewer.as_ref().map(|u| u.id.clone()); | ||
| 120 | let branches = branch_names(state, &ctx.repo.id).await; | 121 | let branches = branch_names(state, &ctx.repo.id).await; |
| 121 | let Loaded { | 122 | let Loaded { |
| 122 | author, | 123 | author, |
| @@ -138,7 +139,12 @@ pub(crate) async fn view( | |||
| 138 | (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches)) | 139 | (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches)) |
| 139 | div class="issue-view" { | 140 | div class="issue-view" { |
| 140 | header class="issue-header" { | 141 | header class="issue-header" { |
| 141 | h1 { (issue.title) " " span class="muted" { "#" (issue.number) } } | 142 | div class="issue-title-line" { |
| 143 | h1 { (issue.title) " " span class="muted" { "#" (issue.number) } } | ||
| 144 | @if can_write || is_author { | ||
| 145 | (edit_issue_form(&issue, Kind::pulls(), &csrf)) | ||
| 146 | } | ||
| 147 | } | ||
| 142 | div class="issue-sub" { | 148 | div class="issue-sub" { |
| 143 | (pr_state_badge(&issue, &pr)) | 149 | (pr_state_badge(&issue, &pr)) |
| 144 | span class="muted" { | 150 | span class="muted" { |
| @@ -149,30 +155,28 @@ pub(crate) async fn view( | |||
| 149 | } | 155 | } |
| 150 | div class="issue-main" { | 156 | div class="issue-main" { |
| 151 | article class="issue-thread" { | 157 | article class="issue-thread" { |
| 152 | (comment_card(&author, &issue.body, issue.created_at)) | 158 | (comment_card(&author, &issue.body, issue.created_at, None)) |
| 153 | @for (c, cauthor) in &comment_rows { | 159 | @for (c, cauthor) in &comment_rows { |
| 154 | (comment_card(cauthor, &c.body, c.created_at)) | 160 | @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str()); |
| 161 | (comment_card(cauthor, &c.body, c.created_at, | ||
| 162 | editable.then_some((c.id.as_str(), csrf.as_str())))) | ||
| 155 | } | 163 | } |
| 156 | (merge_panel(&issue, &pr, mergeable, blocked, &csrf)) | 164 | (merge_panel(&issue, &pr, mergeable, blocked, &csrf)) |
| 157 | @if can_write || is_author { | 165 | @if issue.locked() { (locked_note(can_write)) } |
| 158 | @let comment_form = format!("comment-{}", issue.id); | 166 | @if can_write || (is_author && !issue.locked()) { |
| 159 | div class="card comment-form" { | 167 | (comment_box(&issue, &csrf, Kind::pulls(), can_write)) |
| 160 | form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) { | ||
| 161 | input type="hidden" name="_csrf" value=(csrf); | ||
| 162 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} | ||
| 163 | } | ||
| 164 | div class="comment-actions" { | ||
| 165 | (state_button(&issue, &csrf, Kind::pulls())) | ||
| 166 | button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" } | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | 168 | } |
| 170 | } | 169 | } |
| 171 | aside class="issue-side" { | 170 | aside class="issue-side" { |
| 172 | section { | 171 | section { |
| 173 | h3 { "Assignee" } | 172 | h3 { "Assignee" } |
| 174 | @if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } } | 173 | @if can_write { |
| 175 | @if can_write { (assignee_form(&issue, &ctx, state, &csrf).await) } | 174 | (assignee_form(&issue, &ctx, state, &csrf).await) |
| 175 | } @else if let Some(a) = &assignee { | ||
| 176 | p { (a) } | ||
| 177 | } @else { | ||
| 178 | p class="muted" { "No one" } | ||
| 179 | } | ||
| 176 | } | 180 | } |
| 177 | section { | 181 | section { |
| 178 | h3 { "Labels" } | 182 | h3 { "Labels" } |
crates/web/src/tests.rs +149 −0
| @@ -726,6 +726,155 @@ async fn issues_open_create_and_view() { | |||
| 726 | } | 726 | } |
| 727 | 727 | ||
| 728 | #[tokio::test] | 728 | #[tokio::test] |
| 729 | async fn issue_title_and_comments_are_editable() { | ||
| 730 | let (state, app) = app().await; | ||
| 731 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | ||
| 732 | let repo = state | ||
| 733 | .store | ||
| 734 | .create_repo(NewRepo { | ||
| 735 | owner_id: ada.id.clone(), | ||
| 736 | group_id: None, | ||
| 737 | name: "proj".to_string(), | ||
| 738 | path: "proj".to_string(), | ||
| 739 | description: None, | ||
| 740 | visibility: model::Visibility::Public, | ||
| 741 | default_branch: "main".to_string(), | ||
| 742 | }) | ||
| 743 | .await | ||
| 744 | .unwrap(); | ||
| 745 | let issue = state | ||
| 746 | .store | ||
| 747 | .create_issue(&repo.id, &ada.id, "typo", "body", false) | ||
| 748 | .await | ||
| 749 | .unwrap(); | ||
| 750 | let comment = state | ||
| 751 | .store | ||
| 752 | .add_comment(&issue.id, &ada.id, "orignal") | ||
| 753 | .await | ||
| 754 | .unwrap(); | ||
| 755 | |||
| 756 | let cookie = login(&app).await; | ||
| 757 | let token = cookie | ||
| 758 | .split("fabrica_csrf=") | ||
| 759 | .nth(1) | ||
| 760 | .and_then(|s| s.split(';').next()) | ||
| 761 | .unwrap() | ||
| 762 | .to_string(); | ||
| 763 | let post = |uri: String, body: String, cookie: String| { | ||
| 764 | let app = app.clone(); | ||
| 765 | async move { | ||
| 766 | let req = Request::builder() | ||
| 767 | .method("POST") | ||
| 768 | .uri(uri) | ||
| 769 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
| 770 | .header(header::COOKIE, cookie) | ||
| 771 | .body(Body::from(body)) | ||
| 772 | .unwrap(); | ||
| 773 | app.oneshot(req).await.unwrap() | ||
| 774 | } | ||
| 775 | }; | ||
| 776 | |||
| 777 | // Edit the issue title + body. | ||
| 778 | let res = post( | ||
| 779 | format!("/issue/{}/edit", issue.id), | ||
| 780 | format!("title=Fixed+title&body=new+body&_csrf={token}"), | ||
| 781 | cookie.clone(), | ||
| 782 | ) | ||
| 783 | .await; | ||
| 784 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | ||
| 785 | let updated = state.store.issue_by_id(&issue.id).await.unwrap().unwrap(); | ||
| 786 | assert_eq!(updated.title, "Fixed title"); | ||
| 787 | assert_eq!(updated.body, "new body"); | ||
| 788 | |||
| 789 | // Edit the comment. | ||
| 790 | let res = post( | ||
| 791 | format!("/comment/{}/edit", comment.id), | ||
| 792 | format!("body=corrected&_csrf={token}"), | ||
| 793 | cookie, | ||
| 794 | ) | ||
| 795 | .await; | ||
| 796 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | ||
| 797 | let edited = state | ||
| 798 | .store | ||
| 799 | .comment_by_id(&comment.id) | ||
| 800 | .await | ||
| 801 | .unwrap() | ||
| 802 | .unwrap(); | ||
| 803 | assert_eq!(edited.body, "corrected"); | ||
| 804 | } | ||
| 805 | |||
| 806 | #[tokio::test] | ||
| 807 | async fn issue_can_be_locked_and_unlocked() { | ||
| 808 | let (state, app) = app().await; | ||
| 809 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | ||
| 810 | let repo = state | ||
| 811 | .store | ||
| 812 | .create_repo(NewRepo { | ||
| 813 | owner_id: ada.id.clone(), | ||
| 814 | group_id: None, | ||
| 815 | name: "proj".to_string(), | ||
| 816 | path: "proj".to_string(), | ||
| 817 | description: None, | ||
| 818 | visibility: model::Visibility::Public, | ||
| 819 | default_branch: "main".to_string(), | ||
| 820 | }) | ||
| 821 | .await | ||
| 822 | .unwrap(); | ||
| 823 | let issue = state | ||
| 824 | .store | ||
| 825 | .create_issue(&repo.id, &ada.id, "hot", "", false) | ||
| 826 | .await | ||
| 827 | .unwrap(); | ||
| 828 | |||
| 829 | let cookie = login(&app).await; | ||
| 830 | let token = cookie | ||
| 831 | .split("fabrica_csrf=") | ||
| 832 | .nth(1) | ||
| 833 | .and_then(|s| s.split(';').next()) | ||
| 834 | .unwrap() | ||
| 835 | .to_string(); | ||
| 836 | let lock = |locked: bool, cookie: String| { | ||
| 837 | let app = app.clone(); | ||
| 838 | let token = token.clone(); | ||
| 839 | let id = issue.id.clone(); | ||
| 840 | async move { | ||
| 841 | let req = Request::builder() | ||
| 842 | .method("POST") | ||
| 843 | .uri(format!("/issue/{id}/lock")) | ||
| 844 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
| 845 | .header(header::COOKIE, cookie) | ||
| 846 | .body(Body::from(format!("locked={locked}&_csrf={token}"))) | ||
| 847 | .unwrap(); | ||
| 848 | app.oneshot(req).await.unwrap() | ||
| 849 | } | ||
| 850 | }; | ||
| 851 | |||
| 852 | let res = lock(true, cookie.clone()).await; | ||
| 853 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | ||
| 854 | assert!( | ||
| 855 | state | ||
| 856 | .store | ||
| 857 | .issue_by_id(&issue.id) | ||
| 858 | .await | ||
| 859 | .unwrap() | ||
| 860 | .unwrap() | ||
| 861 | .locked() | ||
| 862 | ); | ||
| 863 | |||
| 864 | let res = lock(false, cookie).await; | ||
| 865 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | ||
| 866 | assert!( | ||
| 867 | !state | ||
| 868 | .store | ||
| 869 | .issue_by_id(&issue.id) | ||
| 870 | .await | ||
| 871 | .unwrap() | ||
| 872 | .unwrap() | ||
| 873 | .locked() | ||
| 874 | ); | ||
| 875 | } | ||
| 876 | |||
| 877 | #[tokio::test] | ||
| 729 | async fn new_issue_can_apply_labels() { | 878 | async fn new_issue_can_apply_labels() { |
| 730 | let (state, app) = app().await; | 879 | let (state, app) = app().await; |
| 731 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | 880 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); |
migrations/postgres/0011_issue_lock.sql +2 −0
| @@ -0,0 +1,2 @@ | |||
| 1 | -- Locking an issue/PR prevents further comments (except by writers). | ||
| 2 | ALTER TABLE issues ADD COLUMN locked_at BIGINT; | ||
migrations/sqlite/0011_issue_lock.sql +2 −0
| @@ -0,0 +1,2 @@ | |||
| 1 | -- Locking an issue/PR prevents further comments (except by writers). | ||
| 2 | ALTER TABLE issues ADD COLUMN locked_at BIGINT; | ||