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 {
514514 .issue-header h1 {
515515 margin: 0;
516516 }
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+}
517553 .issue-sub {
518554 display: flex;
519555 align-items: center;
@@ -549,6 +585,9 @@ body.drawer-open .drawer-backdrop {
549585 overflow: hidden;
550586 }
551587 .comment-head {
588+ display: flex;
589+ align-items: center;
590+ gap: 0.35rem;
552591 padding: 0.5rem 0.85rem;
553592 border-bottom: 1px solid var(--fb-border);
554593 font-size: 0.9em;
crates/model/src/entity.rs +10 −0
@@ -350,6 +350,16 @@ pub struct Issue {
350350 pub updated_at: Timestamp,
351351 /// When it was closed, if it is.
352352 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+ }
353363 }
354364
355365 /// 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
1313 /// The columns of `issues` in a fixed order.
1414 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";
1616
1717 /// Open/closed counts for a repo's issues or PRs.
1818 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -64,6 +64,7 @@ impl Store {
6464 created_at: now,
6565 updated_at: now,
6666 closed_at: None,
67+ locked_at: None,
6768 };
6869 let sql = "INSERT INTO issues \
6970 (id, repo_id, number, author_id, title, body, state, is_pull, assignee_id, \
@@ -231,6 +232,25 @@ impl Store {
231232 Ok(updated > 0)
232233 }
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+
234254 /// Set (or clear) an issue/PR's assignee.
235255 ///
236256 /// # Errors
@@ -346,6 +366,49 @@ impl Store {
346366 .map_err(Into::into)
347367 }
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+
349412 // ---- Issue labels ----
350413
351414 /// The labels applied to an issue/PR.
@@ -454,6 +517,7 @@ impl Store {
454517 created_at: now,
455518 updated_at: now,
456519 closed_at: None,
520+ locked_at: None,
457521 };
458522 sqlx::query(
459523 "INSERT INTO issues \
@@ -600,7 +664,7 @@ impl Store {
600664 pub async fn dependencies_of(&self, issue_id: &str) -> Result<Vec<Issue>, StoreError> {
601665 self.related_issues(
602666 "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 \
604668 FROM issue_dependencies d JOIN issues i ON i.id = d.depends_on_id \
605669 WHERE d.issue_id = $1 ORDER BY i.number ASC",
606670 issue_id,
@@ -616,7 +680,7 @@ impl Store {
616680 pub async fn dependents_of(&self, issue_id: &str) -> Result<Vec<Issue>, StoreError> {
617681 self.related_issues(
618682 "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 \
620684 FROM issue_dependencies d JOIN issues i ON i.id = d.issue_id \
621685 WHERE d.depends_on_id = $1 ORDER BY i.number ASC",
622686 issue_id,
@@ -667,6 +731,7 @@ impl Store {
667731 created_at: row.try_get("created_at")?,
668732 updated_at: row.try_get("updated_at")?,
669733 closed_at: row.try_get("closed_at")?,
734+ locked_at: row.try_get("locked_at")?,
670735 })
671736 }
672737 }
crates/web/src/issues.rs +240 −21
@@ -230,6 +230,7 @@ pub(crate) async fn view(
230230
231231 let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
232232 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());
233234 let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
234235
235236 let mut comment_rows = Vec::with_capacity(comments.len());
@@ -243,7 +244,12 @@ pub(crate) async fn view(
243244 (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches))
244245 div class="issue-view" {
245246 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+ }
247253 div class="issue-sub" {
248254 (state_badge(issue.state, kind.is_pull))
249255 span class="muted" { " " (author) " opened this " (kind.noun) }
@@ -251,33 +257,26 @@ pub(crate) async fn view(
251257 }
252258 div class="issue-main" {
253259 article class="issue-thread" {
254- (comment_card(&author, &issue.body, issue.created_at))
260+ (comment_card(&author, &issue.body, issue.created_at, None))
255261 @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()))))
257265 }
258- @if can_write || is_author {
259- @let comment_form = format!("comment-{}", issue.id);
260- div class="card comment-form" {
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- }
266+ @if issue.locked() { (locked_note(can_write)) }
267+ @if can_write || (is_author && !issue.locked()) {
268+ (comment_box(&issue, &csrf, kind, can_write))
273269 }
274270 }
275271 aside class="issue-side" {
276272 section {
277273 h3 { "Assignee" }
278- @if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } }
279274 @if can_write {
280275 (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" }
281280 }
282281 }
283282 section {
@@ -299,13 +298,29 @@ pub(crate) async fn view(
299298
300299 // ---- shared render helpers ----
301300
302-/// A single comment card (also used for the issue body).
303-pub(crate) fn comment_card(author: &str, body: &str, created_at: i64) -> Markup {
301+/// A single comment card (also used for the issue body). When `edit` is
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 {
304309 html! {
305310 div class="card comment" {
306311 div class="comment-head muted" {
307312 strong { (author) }
308313 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+ }
309324 }
310325 div class="comment-body markdown" { (markdown::render(body)) }
311326 }
@@ -351,6 +366,70 @@ pub(crate) fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {
351366 }
352367 }
353368
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+
354433 /// The assignee select form (owner + collaborators).
355434 pub(crate) async fn assignee_form(
356435 issue: &Issue,
@@ -613,6 +692,96 @@ pub async fn create(
613692 respond_redirect(result)
614693 }
615694
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+
616785 /// Comment form.
617786 #[derive(Debug, Deserialize)]
618787 pub struct CommentForm {
@@ -647,6 +816,12 @@ pub async fn comment(
647816 if access < auth::Access::Read {
648817 return Err(AppError::NotFound);
649818 }
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+ }
650825 let body = form.body.trim();
651826 if !body.is_empty() {
652827 state.store.add_comment(&issue.id, &user.id, body).await?;
@@ -657,6 +832,50 @@ pub async fn comment(
657832 respond_redirect(result)
658833 }
659834
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+
660879 /// State-change form.
661880 #[derive(Debug, Deserialize)]
662881 pub struct StateForm {
crates/web/src/lib.rs +4 −0
@@ -120,6 +120,7 @@ pub(crate) fn now_ms() -> i64 {
120120 }
121121
122122 /// Build the application router with all middleware wired.
123+#[allow(clippy::too_many_lines)] // A flat route table; splitting it hurts readability.
123124 pub fn build_router(state: AppState) -> Router {
124125 let x_request_id = HeaderName::from_static("x-request-id");
125126
@@ -203,6 +204,9 @@ pub fn build_router(state: AppState) -> Router {
203204 .route("/issue/{id}/labels", post(issues::set_labels))
204205 .route("/issue/{id}/deps/add", post(issues::deps_add))
205206 .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))
206210 .route("/pull-new/{repo_id}", post(pulls::create))
207211 .route("/pull/{id}/merge", post(pulls::merge))
208212 .route("/healthz", get(serve_assets::healthz))
crates/web/src/pulls.rs +23 −19
@@ -25,8 +25,8 @@ use crate::AppState;
2525 use crate::diff;
2626 use crate::error::{AppError, AppResult};
2727 use crate::issues::{
28- Kind, access_for, assignee_form, comment_card, deps_section, issue_url, label_form,
29- repo_of_issue, respond_redirect, state_badge, state_button, username,
28+ Kind, access_for, assignee_form, comment_box, comment_card, deps_section, edit_issue_form,
29+ issue_url, label_form, locked_note, repo_of_issue, respond_redirect, state_badge, username,
3030 };
3131 use crate::layout::page;
3232 use crate::pages::build_chrome;
@@ -117,6 +117,7 @@ pub(crate) async fn view(
117117
118118 let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
119119 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());
120121 let branches = branch_names(state, &ctx.repo.id).await;
121122 let Loaded {
122123 author,
@@ -138,7 +139,12 @@ pub(crate) async fn view(
138139 (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches))
139140 div class="issue-view" {
140141 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+ }
142148 div class="issue-sub" {
143149 (pr_state_badge(&issue, &pr))
144150 span class="muted" {
@@ -149,30 +155,28 @@ pub(crate) async fn view(
149155 }
150156 div class="issue-main" {
151157 article class="issue-thread" {
152- (comment_card(&author, &issue.body, issue.created_at))
158+ (comment_card(&author, &issue.body, issue.created_at, None))
153159 @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()))))
155163 }
156164 (merge_panel(&issue, &pr, mergeable, blocked, &csrf))
157- @if can_write || is_author {
158- @let comment_form = format!("comment-{}", issue.id);
159- div class="card comment-form" {
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- }
165+ @if issue.locked() { (locked_note(can_write)) }
166+ @if can_write || (is_author && !issue.locked()) {
167+ (comment_box(&issue, &csrf, Kind::pulls(), can_write))
169168 }
170169 }
171170 aside class="issue-side" {
172171 section {
173172 h3 { "Assignee" }
174- @if let Some(a) = &assignee { p { (a) } } @else { p class="muted" { "No one" } }
175- @if can_write { (assignee_form(&issue, &ctx, state, &csrf).await) }
173+ @if can_write {
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+ }
176180 }
177181 section {
178182 h3 { "Labels" }
crates/web/src/tests.rs +149 −0
@@ -726,6 +726,155 @@ async fn issues_open_create_and_view() {
726726 }
727727
728728 #[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]
729878 async fn new_issue_can_apply_labels() {
730879 let (state, app) = app().await;
731880 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;