fabrica

hanna/fabrica

feat(web): edit issue/PR title & comments, lock conversations, assignee tidy

eb14334 · hanna committed on 2026-07-25

Add an edit toggle on the issue/PR header (title + description) and an inline
edit form on each comment (author or writer), backed by /issue/{id}/edit and
/comment/{id}/edit (store update_issue/update_comment). Add conversation locking
(migration 0011 issues.locked_at): writers get a Lock/Unlock button, a locked
notice shows, and the comment endpoint rejects non-writers while locked. Drop
the redundant assignee name line — the selector already shows it. Extract
comment_box/locked_note helpers to keep the views under the line limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
9 files changed · +537 −43UnifiedSplit
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
357impl 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}
354364
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};
1212
13/// The columns of `issues` in a fixed order.13/// The columns of `issues` in a fixed order.
14const ISSUE_COLUMNS: &str = "id, repo_id, number, author_id, title, body, state, is_pull, \14const 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";
1616
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 }
233234
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 /// # Errors256 /// # Errors
@@ -346,6 +366,49 @@ impl Store {
346 .map_err(Into::into)366 .map_err(Into::into)
347 }367 }
348368
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 ----
350413
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(
230230
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;
234235
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(
299298
300// ---- shared render helpers ----299// ---- shared render helpers ----
301300
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
303pub(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.
303pub(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}
353368
369/// The "conversation is locked" notice.
370pub(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.
381pub(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.
399pub(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.
415pub(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).
355pub(crate) async fn assignee_form(434pub(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}
615694
695/// Edit-issue form (title + description).
696#[derive(Debug, Deserialize)]
697pub 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).
711pub 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).
748pub 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)]
618pub struct CommentForm {787pub 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}
659834
835/// Lock/unlock form.
836#[derive(Debug, Deserialize)]
837pub 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).
847pub 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)]
662pub struct StateForm {881pub struct StateForm {
crates/web/src/lib.rs +4 −0
@@ -120,6 +120,7 @@ pub(crate) fn now_ms() -> i64 {
120}120}
121121
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.
123pub fn build_router(state: AppState) -> Router {124pub 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");
125126
@@ -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;
25use crate::diff;25use crate::diff;
26use crate::error::{AppError, AppResult};26use crate::error::{AppError, AppResult};
27use crate::issues::{27use 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};
31use crate::layout::page;31use crate::layout::page;
32use crate::pages::build_chrome;32use crate::pages::build_chrome;
@@ -117,6 +117,7 @@ pub(crate) async fn view(
117117
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}
727727
728#[tokio::test]728#[tokio::test]
729async 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]
807async 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]
729async fn new_issue_can_apply_labels() {878async 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).
2ALTER 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).
2ALTER TABLE issues ADD COLUMN locked_at BIGINT;