fabrica

hanna/fabrica

46000 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Issues: list, create, view, comment, label, assign, and open/close.
6//!
7//! Pull requests share the underlying `issues` table and much of this rendering;
8//! the PR-specific views live in [`crate::pulls`].
9
10use axum::Form;
11use axum::extract::{Path, Query, State};
12use axum::http::{HeaderMap, Uri};
13use axum::response::{IntoResponse, Redirect, Response};
14use axum_extra::extract::cookie::CookieJar;
15use maud::{Markup, html};
16use model::{Issue, IssueState, Label, User};
17use serde::Deserialize;
18
19use crate::AppState;
20use crate::error::{AppError, AppResult};
21use crate::icons::{Icon, icon};
22use crate::layout::page;
23use crate::markdown;
24use crate::pages::build_chrome;
25use crate::repo::{RepoCtx, Tab, css_color, label_chip, repo_header};
26use crate::session::{MaybeUser, verify_csrf};
27
28/// Whether these views are for issues or pull requests. Keeps the shared
29/// rendering parameterized so pulls can reuse it.
30#[derive(Clone, Copy)]
31pub(crate) struct Kind {
32 /// True for pull requests.
33 pub is_pull: bool,
34 /// URL segment (`issues` | `pulls`).
35 pub seg: &'static str,
36 /// Human noun (`issue` | `pull request`).
37 pub noun: &'static str,
38 /// The active repo tab.
39 pub tab: Tab,
40}
41
42impl Kind {
43 /// The issues kind.
44 pub(crate) fn issues() -> Self {
45 Self {
46 is_pull: false,
47 seg: "issues",
48 noun: "issue",
49 tab: Tab::Issues,
50 }
51 }
52
53 /// The pull-requests kind.
54 pub(crate) fn pulls() -> Self {
55 Self {
56 is_pull: true,
57 seg: "pulls",
58 noun: "pull request",
59 tab: Tab::Pulls,
60 }
61 }
62}
63
64/// `GET …/-/issues` — the issue list with open/closed tabs.
65pub(crate) async fn list(
66 state: &AppState,
67 viewer: Option<User>,
68 jar: CookieJar,
69 uri: &Uri,
70 ctx: RepoCtx,
71 kind: Kind,
72 show_closed: bool,
73) -> AppResult<Response> {
74 if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) {
75 return Err(AppError::NotFound);
76 }
77 let filter = if show_closed {
78 IssueState::Closed
79 } else {
80 IssueState::Open
81 };
82 // Paginate: fetch one extra row to detect a next page.
83 let paging = crate::repo::Pagination::from_query(uri.query());
84 let limit = i64::try_from(paging.per_page + 1).unwrap_or(i64::MAX);
85 let offset = i64::try_from(paging.offset()).unwrap_or(0);
86 let mut items = state
87 .store
88 .list_issues(&ctx.repo.id, kind.is_pull, Some(filter), limit, offset)
89 .await?;
90 let has_next = items.len() > paging.per_page;
91 items.truncate(paging.per_page);
92 let counts = state.store.issue_counts(&ctx.repo.id, kind.is_pull).await?;
93
94 // Resolve author usernames and each item's labels.
95 let mut rows = Vec::with_capacity(items.len());
96 for issue in &items {
97 let author = username(state, &issue.author_id).await;
98 let labels = state.store.issue_labels(&issue.id).await?;
99 rows.push((issue, author, labels));
100 }
101
102 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
103 let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
104 let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
105 let body = html! {
106 (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches))
107 div class="issues-head" {
108 nav class="subnav" aria-label=(kind.noun) {
109 a href=(format!("{base}/-/{}?state=open", kind.seg))
110 aria-current=[(!show_closed).then_some("page")] {
111 (icon(Icon::Issue)) span { (counts.open) " Open" }
112 }
113 a href=(format!("{base}/-/{}?state=closed", kind.seg))
114 aria-current=[show_closed.then_some("page")] {
115 span { (counts.closed) " Closed" }
116 }
117 }
118 @if can_write {
119 a class="btn btn-primary" href=(format!("{base}/-/{}/new", kind.seg)) {
120 (icon(Icon::Plus)) span { "New " (kind.noun) }
121 }
122 }
123 }
124 @if rows.is_empty() {
125 div class="card" { p class="muted" {
126 "No " (if show_closed { "closed" } else { "open" }) " " (kind.noun) "s."
127 } }
128 } @else {
129 ul class="entry-list card issue-list" {
130 @for (issue, author, labels) in &rows {
131 li class="entry-row" {
132 div class="entry-row-body" {
133 div class="issue-title-row" {
134 (state_icon(issue.state, kind.is_pull))
135 a class="entry-row-title" href=(format!("{base}/-/{}/{}", kind.seg, issue.number)) {
136 (issue.title)
137 }
138 @for l in labels { (label_chip(l)) }
139 }
140 div class="entry-row-meta muted" {
141 "#" (issue.number) " · opened by " (author)
142 }
143 }
144 }
145 }
146 }
147 @if has_next || paging.page > 1 {
148 (crate::repo::pagination_nav(uri.path(), uri.query(), paging, has_next))
149 }
150 }
151 };
152 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
153 let title = format!("{}/{}: {}s", ctx.owner.username, ctx.repo.path, kind.noun);
154 Ok((jar, page(&chrome, &title, body)).into_response())
155}
156
157/// `GET …/-/issues/new` — the new-issue form.
158pub(crate) async fn new_form(
159 state: &AppState,
160 viewer: Option<User>,
161 jar: CookieJar,
162 uri: &Uri,
163 ctx: RepoCtx,
164 kind: Kind,
165) -> AppResult<Response> {
166 if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) {
167 return Err(AppError::NotFound);
168 }
169 // Opening an issue requires Read (the resolve already guaranteed it) and a
170 // signed-in viewer.
171 if viewer.is_none() {
172 return Ok(Redirect::to("/login").into_response());
173 }
174 let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
175 let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
176 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
177 let body = html! {
178 (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches))
179 h2 { "New " (kind.noun) }
180 div class="card" {
181 form method="post" action=(format!("/issue-new/{}?is_pull={}", ctx.repo.id, kind.is_pull)) {
182 input type="hidden" name="_csrf" value=(chrome.csrf);
183 label for="title" { "Title" }
184 input type="text" id="title" name="title" required autofocus;
185 label for="body" { "Description" }
186 textarea id="body" name="body" rows="8" placeholder="Markdown supported" {}
187 div class="new-issue-actions" {
188 button class="btn btn-primary inline-btn" type="submit" { "Create " (kind.noun) }
189 @if !repo_labels.is_empty() {
190 (label_dropdown(&repo_labels))
191 }
192 }
193 }
194 }
195 };
196 let title = format!(
197 "New {} · {}/{}",
198 kind.noun, ctx.owner.username, ctx.repo.path
199 );
200 Ok((jar, page(&chrome, &title, body)).into_response())
201}
202
203/// `GET …/-/issues/{n}` — a single issue with comments and controls.
204#[allow(clippy::too_many_lines)] // Cohesive view: header, thread, sidebar.
205pub(crate) async fn view(
206 state: &AppState,
207 viewer: Option<User>,
208 jar: CookieJar,
209 uri: &Uri,
210 ctx: RepoCtx,
211 kind: Kind,
212 number: i64,
213) -> AppResult<Response> {
214 if (kind.is_pull && !ctx.repo.pulls_enabled) || (!kind.is_pull && !ctx.repo.issues_enabled) {
215 return Err(AppError::NotFound);
216 }
217 let Some(issue) = state
218 .store
219 .issue_by_number(&ctx.repo.id, number, kind.is_pull)
220 .await?
221 else {
222 // A `#n` reference may point at the other kind — redirect there.
223 if let Some(other) = state
224 .store
225 .issue_by_number(&ctx.repo.id, number, !kind.is_pull)
226 .await?
227 {
228 let seg = if other.is_pull { "pulls" } else { "issues" };
229 let dest = format!("/{}/{}/-/{seg}/{number}", ctx.owner.username, ctx.repo.path);
230 return Ok(Redirect::to(&dest).into_response());
231 }
232 return Err(AppError::NotFound);
233 };
234 let author = username(state, &issue.author_id).await;
235 let comments = state.store.list_comments(&issue.id).await?;
236 let issue_labels = state.store.issue_labels(&issue.id).await?;
237 let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
238 let assignee = match &issue.assignee_id {
239 Some(id) => Some(username(state, id).await),
240 None => None,
241 };
242 let deps = state.store.dependencies_of(&issue.id).await?;
243 let dependents = state.store.dependents_of(&issue.id).await?;
244
245 let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
246 let is_author = viewer.as_ref().is_some_and(|v| v.id == issue.author_id);
247 let viewer_id: Option<String> = viewer.as_ref().map(|u| u.id.clone());
248 let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
249
250 // Paginate long threads (the pager only appears past one page).
251 let paging = crate::repo::Pagination::from_query(uri.query());
252 let (page_comments, comments_next) = crate::repo::page_slice(&comments, paging);
253 let mut comment_rows = Vec::with_capacity(page_comments.len());
254 for c in &page_comments {
255 comment_rows.push((c, username(state, &c.author_id).await));
256 }
257
258 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
259 let csrf = chrome.csrf.clone();
260 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
261 let body = html! {
262 (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches))
263 div class="issue-view" {
264 header class="issue-header" {
265 div class="issue-title-line" {
266 h1 { (issue.title) " " span class="muted" { "#" (issue.number) } }
267 @if can_write || is_author {
268 (edit_issue_form(&issue, &csrf))
269 }
270 }
271 div class="issue-sub" {
272 (state_badge(issue.state, kind.is_pull))
273 span class="muted" { " " (author) " opened this " (kind.noun) }
274 }
275 }
276 div class="issue-main" {
277 article class="issue-thread" {
278 @let body_action = format!("/issue/{}/edit-body", issue.id);
279 (comment_card(&author, &base, &issue.body, issue.created_at,
280 (can_write || is_author).then_some((body_action.as_str(), csrf.as_str()))))
281 @for (c, cauthor) in &comment_rows {
282 @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str());
283 @let action = format!("/comment/{}/edit", c.id);
284 (comment_card(cauthor, &base, &c.body, c.created_at,
285 editable.then_some((action.as_str(), csrf.as_str()))))
286 }
287 @if comments_next || paging.page > 1 {
288 (crate::repo::pagination_nav(uri.path(), uri.query(), paging, comments_next))
289 }
290 @if issue.locked() { (locked_note(can_write)) }
291 @if can_write || (is_author && !issue.locked()) {
292 (comment_box(&issue, &csrf, can_write))
293 }
294 }
295 aside class="issue-side" {
296 section {
297 h3 { "Assignee" }
298 @if can_write {
299 (assignee_form(&issue, &ctx, state, &csrf).await)
300 } @else if let Some(a) = &assignee {
301 p { (a) }
302 } @else {
303 p class="muted" { "No one" }
304 }
305 }
306 section {
307 h3 { "Labels" }
308 @if can_write && !repo_labels.is_empty() {
309 (label_form(&issue, &repo_labels, &issue_labels, &csrf))
310 } @else if issue_labels.is_empty() {
311 p class="muted" { "None" }
312 } @else {
313 div class="label-set" { @for l in &issue_labels { (label_chip(l)) } }
314 }
315 }
316 (deps_section(&issue, &ctx, &deps, &dependents, &csrf, can_write))
317 }
318 }
319 }
320 };
321 let title = format!("{} · {}/{}", issue.title, ctx.owner.username, ctx.repo.path);
322 Ok((jar, page(&chrome, &title, body)).into_response())
323}
324
325// ---- shared render helpers ----
326
327/// A single comment card (also used for the issue body). When `edit` is
328/// `Some((action, csrf))`, the header offers an inline edit form posting to
329/// `action` — `/comment/{id}/edit` for comments, `/issue/{id}/edit-body` for the
330/// issue/PR description.
331pub(crate) fn comment_card(
332 author: &str,
333 repo_base: &str,
334 body: &str,
335 created_at: i64,
336 edit: Option<(&str, &str)>,
337) -> Markup {
338 html! {
339 div class="card comment" {
340 div class="comment-head muted" {
341 strong { (author) }
342 span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) }
343 // A bare toggle; CSS `:has(.comment-edit[open])` swaps the rendered
344 // body for the editor below, so no JavaScript is needed.
345 @if edit.is_some() {
346 details class="comment-edit" {
347 summary {
348 span class="edit-open" { "Edit" }
349 span class="edit-close" { "Cancel" }
350 }
351 }
352 }
353 }
354 div class="comment-body markdown" { (markdown::render_with_refs(body, repo_base)) }
355 @if let Some((action, csrf)) = edit {
356 form class="comment-editor" method="post" action=(action) {
357 input type="hidden" name="_csrf" value=(csrf);
358 textarea name="body" rows="6" required { (body) }
359 div class="comment-editor-actions" {
360 button class="btn btn-primary inline-btn" type="submit" { "Save" }
361 }
362 }
363 }
364 }
365 }
366}
367
368/// The open/closed state icon.
369fn state_icon(st: IssueState, is_pull: bool) -> Markup {
370 let glyph = if is_pull { Icon::GitPull } else { Icon::Issue };
371 let class = if st == IssueState::Open {
372 "state-open"
373 } else {
374 "state-closed"
375 };
376 html! { span class=(class) { (icon(glyph)) } }
377}
378
379/// The open/closed state badge.
380pub(crate) fn state_badge(st: IssueState, is_pull: bool) -> Markup {
381 let (class, label) = match st {
382 IssueState::Open => ("badge badge-state-open", "Open"),
383 IssueState::Closed => ("badge badge-state-closed", "Closed"),
384 };
385 html! {
386 span class=(class) {
387 (state_icon(st, is_pull)) " " (label)
388 }
389 }
390}
391
392/// The close/reopen button (posts to the state endpoint).
393pub(crate) fn state_button(issue: &Issue, csrf: &str) -> Markup {
394 let (target, label) = match issue.state {
395 IssueState::Open => ("closed", "Close"),
396 IssueState::Closed => ("open", "Reopen"),
397 };
398 html! {
399 form method="post" action=(format!("/issue/{}/state", issue.id)) class="inline-form" {
400 input type="hidden" name="_csrf" value=(csrf);
401 input type="hidden" name="state" value=(target);
402 button class="btn inline-btn" type="submit" { (label) }
403 }
404 }
405}
406
407/// The "conversation is locked" notice.
408pub(crate) fn locked_note(can_write: bool) -> Markup {
409 html! {
410 div class="card locked-note muted" {
411 "🔒 This conversation is locked."
412 @if can_write { " Only those with write access can comment." }
413 }
414 }
415}
416
417/// The comment box: a textarea plus the state/lock/comment actions. The Comment
418/// button references the form via `form=` so no forms are nested.
419pub(crate) fn comment_box(issue: &Issue, csrf: &str, can_write: bool) -> Markup {
420 let comment_form = format!("comment-{}", issue.id);
421 html! {
422 div class="card comment-form" {
423 form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) {
424 input type="hidden" name="_csrf" value=(csrf);
425 textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {}
426 }
427 div class="comment-actions" {
428 (state_button(issue, csrf))
429 @if can_write { (lock_button(issue, csrf)) }
430 button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" }
431 }
432 }
433 }
434}
435
436/// The lock/unlock button (posts to the lock endpoint). Writers only.
437pub(crate) fn lock_button(issue: &Issue, csrf: &str) -> Markup {
438 let (target, label) = if issue.locked() {
439 ("false", "Unlock")
440 } else {
441 ("true", "Lock")
442 };
443 html! {
444 form method="post" action=(format!("/issue/{}/lock", issue.id)) class="inline-form" {
445 input type="hidden" name="_csrf" value=(csrf);
446 input type="hidden" name="locked" value=(target);
447 button class="btn inline-btn" type="submit" { (label) }
448 }
449 }
450}
451
452/// The edit-title toggle in the issue/PR header. When open it hides the title and
453/// shows a full-width single-line input with Cancel (the toggle) and Save beside
454/// it, GitHub-style. (The description is edited from its own comment card.)
455pub(crate) fn edit_issue_form(issue: &Issue, csrf: &str) -> Markup {
456 // The toggle, the input form, and Save are siblings of the title (not nested
457 // in the <details>), so `:has(.issue-edit[open])` can reveal them without
458 // hitting the ::details-content wrapper that would stack them.
459 let form_id = format!("title-edit-{}", issue.id);
460 html! {
461 details class="issue-edit" {
462 summary class="btn edit-toggle" {
463 span class="edit-open" { "Edit" }
464 span class="edit-close" { "Cancel" }
465 }
466 }
467 form id=(form_id) class="title-edit-form" method="post"
468 action=(format!("/issue/{}/edit-title", issue.id)) {
469 input type="hidden" name="_csrf" value=(csrf);
470 input type="text" name="title" value=(issue.title) aria-label="Title" required;
471 }
472 button class="btn btn-primary title-save" type="submit" form=(form_id) { "Save" }
473 }
474}
475
476/// The assignee select form (owner + collaborators).
477pub(crate) async fn assignee_form(
478 issue: &Issue,
479 ctx: &RepoCtx,
480 state: &AppState,
481 csrf: &str,
482) -> Markup {
483 let mut options: Vec<(String, String)> =
484 vec![(ctx.owner.id.clone(), ctx.owner.username.clone())];
485 if let Ok(collabs) = state.store.list_collaborators(&ctx.repo.id).await {
486 for c in collabs {
487 options.push((c.user_id, c.username));
488 }
489 }
490 html! {
491 form method="post" action=(format!("/issue/{}/assignee", issue.id)) class="side-form" {
492 input type="hidden" name="_csrf" value=(csrf);
493 select name="assignee_id" aria-label="Assignee" {
494 option value="" selected[issue.assignee_id.is_none()] { "No one" }
495 @for (id, name) in &options {
496 option value=(id) selected[issue.assignee_id.as_deref() == Some(id.as_str())] { (name) }
497 }
498 }
499 button class="btn inline-btn" type="submit" { "Set" }
500 }
501 }
502}
503
504/// The label editor for an existing issue/PR: a `<details>` dropdown whose toggle
505/// shows the applied labels as chips (JS replaces the "Select labels" text), the
506/// same control as the new-issue picker. Toggling a label auto-submits the whole
507/// set; a noscript Apply button keeps it working without JS.
508pub(crate) fn label_form(
509 issue: &Issue,
510 repo_labels: &[Label],
511 applied: &[Label],
512 csrf: &str,
513) -> Markup {
514 let is_on = |id: &str| applied.iter().any(|l| l.id == id);
515 html! {
516 form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form" {
517 input type="hidden" name="_csrf" value=(csrf);
518 details class="menu label-dropdown" data-menu data-label-dropdown {
519 summary class="btn" {
520 span class="label-dropdown-label" data-label-summary { "Select labels" }
521 (icon(Icon::ChevronDown))
522 }
523 div class="menu-popover label-menu" {
524 @for l in repo_labels {
525 label class="checkbox" {
526 input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)]
527 data-label-name=(l.name)
528 data-label-color=(css_color(&l.color))
529 data-autosubmit;
530 " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}
531 " " (l.name)
532 }
533 }
534 noscript { button class="btn inline-btn label-apply" type="submit" { "Apply" } }
535 }
536 }
537 }
538 }
539}
540
541/// A `<details>` dropdown of label checkboxes, for selecting several labels when
542/// opening an issue. The checkboxes submit with the surrounding form; JS replaces
543/// the "Select labels" text inside the toggle with chips for the checked labels.
544fn label_dropdown(repo_labels: &[Label]) -> Markup {
545 html! {
546 details class="menu label-dropdown" data-menu data-label-dropdown {
547 summary class="btn" {
548 span class="label-dropdown-label" data-label-summary { "Select labels" }
549 (icon(Icon::ChevronDown))
550 }
551 div class="menu-popover label-menu" {
552 @for l in repo_labels {
553 label class="checkbox" {
554 input type="checkbox" name="label" value=(l.id)
555 data-label-name=(l.name)
556 data-label-color=(css_color(&l.color));
557 " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}
558 " " (l.name)
559 }
560 }
561 }
562 }
563 }
564}
565
566/// The dependencies sidebar section: "Blocked by" (with add/remove for writers)
567/// and "Blocks" (read-only). Shared by the issue and PR views.
568pub(crate) fn deps_section(
569 issue: &Issue,
570 ctx: &RepoCtx,
571 deps: &[Issue],
572 dependents: &[Issue],
573 csrf: &str,
574 can_write: bool,
575) -> Markup {
576 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
577 let row = |dep: &Issue, removable: bool| {
578 let seg = if dep.is_pull { "pulls" } else { "issues" };
579 html! {
580 li class="dep-row" {
581 (state_icon(dep.state, dep.is_pull))
582 a href=(format!("{base}/-/{seg}/{}", dep.number)) {
583 span class="muted mono" { "#" (dep.number) } " " (dep.title)
584 }
585 @if removable && can_write {
586 form method="post" action=(format!("/issue/{}/deps/remove", issue.id)) class="dep-remove" {
587 input type="hidden" name="_csrf" value=(csrf);
588 input type="hidden" name="depends_on" value=(dep.id);
589 button class="icon-btn inline-btn" type="submit" aria-label="Remove dependency" { (icon(Icon::X)) }
590 }
591 }
592 }
593 }
594 };
595 html! {
596 section {
597 h3 { "Blocked by" }
598 @if deps.is_empty() {
599 p class="muted" { "Nothing" }
600 } @else {
601 ul class="dep-list" { @for d in deps { (row(d, true)) } }
602 }
603 @if can_write {
604 form method="post" action=(format!("/issue/{}/deps/add", issue.id)) class="side-form dep-add" {
605 input type="hidden" name="_csrf" value=(csrf);
606 input type="number" name="number" min="1" placeholder="#123" aria-label="Depends on number" required;
607 button class="btn inline-btn" type="submit" { "Add" }
608 }
609 }
610 }
611 @if !dependents.is_empty() {
612 section {
613 h3 { "Blocks" }
614 ul class="dep-list" { @for d in dependents { (row(d, false)) } }
615 }
616 }
617 }
618}
619
620/// Resolve a username for display, falling back to the id.
621pub(crate) async fn username(state: &AppState, user_id: &str) -> String {
622 state
623 .store
624 .user_by_id(user_id)
625 .await
626 .ok()
627 .flatten()
628 .map_or_else(|| user_id.to_string(), |u| u.username)
629}
630
631// ---- POST handlers (fixed-prefix routes, keyed by id) ----
632
633/// Load an issue's repo and the viewer's access to it.
634pub(crate) async fn repo_of_issue(
635 state: &AppState,
636 viewer: Option<&User>,
637 issue: &Issue,
638) -> AppResult<(model::Repo, auth::Access)> {
639 let repo = state
640 .store
641 .repo_by_id(&issue.repo_id)
642 .await?
643 .ok_or(AppError::NotFound)?;
644 let access = access_for(state, viewer, &repo).await?;
645 Ok((repo, access))
646}
647
648/// Compute the viewer's access to a repo.
649pub(crate) async fn access_for(
650 state: &AppState,
651 viewer: Option<&User>,
652 repo: &model::Repo,
653) -> AppResult<auth::Access> {
654 let viewer_id = viewer.map(|u| auth::Viewer {
655 id: u.id.clone(),
656 is_admin: u.is_admin,
657 });
658 let collab = match viewer {
659 Some(u) => state
660 .store
661 .effective_permission(&repo.id, &u.id)
662 .await?
663 .and_then(|p| auth::Permission::parse(&p)),
664 None => None,
665 };
666 Ok(auth::access(
667 viewer_id.as_ref(),
668 repo,
669 collab,
670 state.allow_anonymous(),
671 ))
672}
673
674/// The path to an issue/PR.
675pub(crate) async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issue) -> String {
676 let owner = username(state, &repo.owner_id).await;
677 let seg = if issue.is_pull { "pulls" } else { "issues" };
678 format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number)
679}
680
681/// `?is_pull=` query for the create endpoint.
682#[derive(Debug, Default, Deserialize)]
683pub struct CreateQuery {
684 /// Whether the new item is a pull request.
685 #[serde(default)]
686 is_pull: bool,
687}
688
689/// `POST /issue-new/{repo_id}` — create an issue (PRs use their own flow).
690///
691/// The body is parsed manually because it carries repeated `label` fields, which
692/// `serde_urlencoded` cannot represent.
693pub async fn create(
694 State(state): State<AppState>,
695 MaybeUser(viewer): MaybeUser,
696 jar: CookieJar,
697 headers: HeaderMap,
698 Path(repo_id): Path<String>,
699 Query(q): Query<CreateQuery>,
700 body: axum::body::Bytes,
701) -> Response {
702 let (mut title, mut desc, mut csrf) = (String::new(), String::new(), String::new());
703 let mut chosen: Vec<String> = Vec::new();
704 for (k, v) in url::form_urlencoded::parse(&body) {
705 match k.as_ref() {
706 "title" => title = v.into_owned(),
707 "body" => desc = v.into_owned(),
708 "_csrf" => csrf = v.into_owned(),
709 "label" => chosen.push(v.into_owned()),
710 _ => {}
711 }
712 }
713 if verify_csrf(&jar, &headers, Some(&csrf)).is_err() {
714 return AppError::Forbidden.into_response();
715 }
716 let result = async {
717 let user = viewer.ok_or(AppError::Forbidden)?;
718 let repo = state
719 .store
720 .repo_by_id(&repo_id)
721 .await?
722 .ok_or(AppError::NotFound)?;
723 if q.is_pull || !repo.issues_enabled {
724 return Err(AppError::NotFound);
725 }
726 // Read access is enough to open an issue.
727 if access_for(&state, Some(&user), &repo).await? < auth::Access::Read {
728 return Err(AppError::NotFound);
729 }
730 let title = title.trim();
731 if title.is_empty() {
732 return Err(AppError::BadRequest("A title is required.".to_string()));
733 }
734 let issue = state
735 .store
736 .create_issue(&repo.id, &user.id, title, desc.trim(), false)
737 .await?;
738 // Apply any selected labels that belong to this repo.
739 if !chosen.is_empty() {
740 let valid: std::collections::HashSet<String> = state
741 .store
742 .list_labels(&repo.id)
743 .await?
744 .into_iter()
745 .map(|l| l.id)
746 .collect();
747 for id in chosen.iter().filter(|id| valid.contains(*id)) {
748 state.store.add_issue_label(&issue.id, id).await?;
749 }
750 }
751 notify_mentions(&state, &user.id, &repo, &issue.id, desc.trim()).await;
752 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
753 }
754 .await;
755 respond_redirect(result)
756}
757
758/// Create a "mention" notification for every `@user` in `body` who can read the
759/// repo. Best-effort: failures are ignored.
760pub(crate) async fn notify_mentions(
761 state: &AppState,
762 actor_id: &str,
763 repo: &model::Repo,
764 issue_id: &str,
765 body: &str,
766) {
767 for name in markdown::mentions(body) {
768 let Ok(Some(user)) = state.store.user_by_username(&name).await else {
769 continue;
770 };
771 let collab = state
772 .store
773 .effective_permission(&repo.id, &user.id)
774 .await
775 .ok()
776 .flatten()
777 .and_then(|p| auth::Permission::parse(&p));
778 let viewer = auth::Viewer {
779 id: user.id.clone(),
780 is_admin: user.is_admin,
781 };
782 let access = auth::access(Some(&viewer), repo, collab, state.allow_anonymous());
783 if access >= auth::Access::Read {
784 let _ = state
785 .store
786 .create_notification(store::NewNotification {
787 user_id: user.id,
788 actor_id: actor_id.to_string(),
789 kind: "mention".to_string(),
790 repo_id: Some(repo.id.clone()),
791 issue_id: Some(issue_id.to_string()),
792 })
793 .await;
794 }
795 }
796}
797
798/// Edit-title form.
799#[derive(Debug, Deserialize)]
800pub struct EditTitleForm {
801 /// New title.
802 #[serde(default)]
803 title: String,
804 /// CSRF token.
805 #[serde(rename = "_csrf")]
806 csrf: String,
807}
808
809/// `POST /issue/{id}/edit-title` — edit an issue/PR's title (Write or author).
810pub async fn edit_title(
811 State(state): State<AppState>,
812 MaybeUser(viewer): MaybeUser,
813 jar: CookieJar,
814 headers: HeaderMap,
815 Path(id): Path<String>,
816 Form(form): Form<EditTitleForm>,
817) -> Response {
818 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
819 return AppError::Forbidden.into_response();
820 }
821 let result = async {
822 let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?;
823 let title = form.title.trim();
824 if title.is_empty() {
825 return Err(AppError::BadRequest("A title is required.".to_string()));
826 }
827 state
828 .store
829 .update_issue(&issue.id, title, &issue.body)
830 .await?;
831 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
832 }
833 .await;
834 respond_redirect(result)
835}
836
837/// `POST /issue/{id}/edit-body` — edit an issue/PR's description (Write or
838/// author). Reuses [`CommentForm`] (the `body` field).
839pub async fn edit_body(
840 State(state): State<AppState>,
841 MaybeUser(viewer): MaybeUser,
842 jar: CookieJar,
843 headers: HeaderMap,
844 Path(id): Path<String>,
845 Form(form): Form<CommentForm>,
846) -> Response {
847 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
848 return AppError::Forbidden.into_response();
849 }
850 let result = async {
851 let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?;
852 state
853 .store
854 .update_issue(&issue.id, &issue.title, form.body.trim())
855 .await?;
856 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
857 }
858 .await;
859 respond_redirect(result)
860}
861
862/// Resolve the issue and authorize the viewer to edit it (Write or the author).
863async fn edit_target(
864 state: &AppState,
865 viewer: Option<&User>,
866 id: &str,
867) -> AppResult<(model::Repo, Issue)> {
868 let user = viewer.ok_or(AppError::Forbidden)?;
869 let issue = state
870 .store
871 .issue_by_id(id)
872 .await?
873 .ok_or(AppError::NotFound)?;
874 let (repo, access) = repo_of_issue(state, Some(user), &issue).await?;
875 if access < auth::Access::Write && user.id != issue.author_id {
876 return Err(AppError::Forbidden);
877 }
878 Ok((repo, issue))
879}
880
881/// `POST /comment/{id}/edit` — edit a comment (Write or the comment's author).
882pub async fn comment_edit(
883 State(state): State<AppState>,
884 MaybeUser(viewer): MaybeUser,
885 jar: CookieJar,
886 headers: HeaderMap,
887 Path(id): Path<String>,
888 Form(form): Form<CommentForm>,
889) -> Response {
890 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
891 return AppError::Forbidden.into_response();
892 }
893 let result = async {
894 let user = viewer.ok_or(AppError::Forbidden)?;
895 let comment = state
896 .store
897 .comment_by_id(&id)
898 .await?
899 .ok_or(AppError::NotFound)?;
900 let issue = state
901 .store
902 .issue_by_id(&comment.issue_id)
903 .await?
904 .ok_or(AppError::NotFound)?;
905 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
906 if access < auth::Access::Write && user.id != comment.author_id {
907 return Err(AppError::Forbidden);
908 }
909 let body = form.body.trim();
910 if !body.is_empty() {
911 state.store.update_comment(&comment.id, body).await?;
912 }
913 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
914 }
915 .await;
916 respond_redirect(result)
917}
918
919/// Comment form.
920#[derive(Debug, Deserialize)]
921pub struct CommentForm {
922 /// Markdown body.
923 #[serde(default)]
924 body: String,
925 /// CSRF token.
926 #[serde(rename = "_csrf")]
927 csrf: String,
928}
929
930/// `POST /issue/{id}/comment` — add a comment.
931pub async fn comment(
932 State(state): State<AppState>,
933 MaybeUser(viewer): MaybeUser,
934 jar: CookieJar,
935 headers: HeaderMap,
936 Path(id): Path<String>,
937 Form(form): Form<CommentForm>,
938) -> Response {
939 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
940 return AppError::Forbidden.into_response();
941 }
942 let result = async {
943 let user = viewer.ok_or(AppError::Forbidden)?;
944 let issue = state
945 .store
946 .issue_by_id(&id)
947 .await?
948 .ok_or(AppError::NotFound)?;
949 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
950 if access < auth::Access::Read {
951 return Err(AppError::NotFound);
952 }
953 // A locked conversation only accepts comments from writers.
954 if issue.locked() && access < auth::Access::Write {
955 return Err(AppError::BadRequest(
956 "This conversation is locked.".to_string(),
957 ));
958 }
959 let body = form.body.trim();
960 if !body.is_empty() {
961 state.store.add_comment(&issue.id, &user.id, body).await?;
962 notify_mentions(&state, &user.id, &repo, &issue.id, body).await;
963 }
964 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
965 }
966 .await;
967 respond_redirect(result)
968}
969
970/// Lock/unlock form.
971#[derive(Debug, Deserialize)]
972pub struct LockForm {
973 /// `true` to lock, else unlock.
974 #[serde(default)]
975 locked: String,
976 /// CSRF token.
977 #[serde(rename = "_csrf")]
978 csrf: String,
979}
980
981/// `POST /issue/{id}/lock` — lock or unlock the conversation (Write only).
982pub async fn set_lock(
983 State(state): State<AppState>,
984 MaybeUser(viewer): MaybeUser,
985 jar: CookieJar,
986 headers: HeaderMap,
987 Path(id): Path<String>,
988 Form(form): Form<LockForm>,
989) -> Response {
990 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
991 return AppError::Forbidden.into_response();
992 }
993 let result = async {
994 let user = viewer.ok_or(AppError::Forbidden)?;
995 let issue = state
996 .store
997 .issue_by_id(&id)
998 .await?
999 .ok_or(AppError::NotFound)?;
1000 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
1001 if access < auth::Access::Write {
1002 return Err(AppError::Forbidden);
1003 }
1004 state
1005 .store
1006 .set_issue_locked(&issue.id, form.locked == "true")
1007 .await?;
1008 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
1009 }
1010 .await;
1011 respond_redirect(result)
1012}
1013
1014/// State-change form.
1015#[derive(Debug, Deserialize)]
1016pub struct StateForm {
1017 /// `open` | `closed`.
1018 #[serde(default)]
1019 state: String,
1020 /// CSRF token.
1021 #[serde(rename = "_csrf")]
1022 csrf: String,
1023}
1024
1025/// `POST /issue/{id}/state` — open/close (author or Write).
1026pub async fn set_state(
1027 State(state): State<AppState>,
1028 MaybeUser(viewer): MaybeUser,
1029 jar: CookieJar,
1030 headers: HeaderMap,
1031 Path(id): Path<String>,
1032 Form(form): Form<StateForm>,
1033) -> Response {
1034 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1035 return AppError::Forbidden.into_response();
1036 }
1037 let result = async {
1038 let user = viewer.ok_or(AppError::Forbidden)?;
1039 let issue = state
1040 .store
1041 .issue_by_id(&id)
1042 .await?
1043 .ok_or(AppError::NotFound)?;
1044 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
1045 let allowed = access >= auth::Access::Write || user.id == issue.author_id;
1046 if !allowed {
1047 return Err(AppError::Forbidden);
1048 }
1049 state
1050 .store
1051 .set_issue_state(&issue.id, IssueState::from_token(&form.state))
1052 .await?;
1053 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
1054 }
1055 .await;
1056 respond_redirect(result)
1057}
1058
1059/// Assignee form.
1060#[derive(Debug, Deserialize)]
1061pub struct AssigneeForm {
1062 /// The assignee user id (empty clears it).
1063 #[serde(default)]
1064 assignee_id: String,
1065 /// CSRF token.
1066 #[serde(rename = "_csrf")]
1067 csrf: String,
1068}
1069
1070/// `POST /issue/{id}/assignee` — set/clear the assignee (Write).
1071pub async fn set_assignee(
1072 State(state): State<AppState>,
1073 MaybeUser(viewer): MaybeUser,
1074 jar: CookieJar,
1075 headers: HeaderMap,
1076 Path(id): Path<String>,
1077 Form(form): Form<AssigneeForm>,
1078) -> Response {
1079 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1080 return AppError::Forbidden.into_response();
1081 }
1082 let result = async {
1083 let user = viewer.ok_or(AppError::Forbidden)?;
1084 let issue = state
1085 .store
1086 .issue_by_id(&id)
1087 .await?
1088 .ok_or(AppError::NotFound)?;
1089 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
1090 if access < auth::Access::Write {
1091 return Err(AppError::Forbidden);
1092 }
1093 let assignee = form.assignee_id.trim();
1094 state
1095 .store
1096 .set_issue_assignee(&issue.id, (!assignee.is_empty()).then_some(assignee))
1097 .await?;
1098 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
1099 }
1100 .await;
1101 respond_redirect(result)
1102}
1103
1104/// `POST /issue/{id}/labels` — replace the applied labels (Write). Scoped labels
1105/// are kept mutually exclusive within their scope by the checkbox set itself.
1106pub async fn set_labels(
1107 State(state): State<AppState>,
1108 MaybeUser(viewer): MaybeUser,
1109 jar: CookieJar,
1110 headers: HeaderMap,
1111 Path(id): Path<String>,
1112 body: axum::body::Bytes,
1113) -> Response {
1114 // Parse the repeated `label` fields and `_csrf` from the urlencoded body
1115 // (serde_urlencoded has no sequence support).
1116 let mut chosen: Vec<String> = Vec::new();
1117 let mut csrf = String::new();
1118 for (k, v) in url::form_urlencoded::parse(&body) {
1119 match k.as_ref() {
1120 "label" => chosen.push(v.into_owned()),
1121 "_csrf" => csrf = v.into_owned(),
1122 _ => {}
1123 }
1124 }
1125 if verify_csrf(&jar, &headers, Some(&csrf)).is_err() {
1126 return AppError::Forbidden.into_response();
1127 }
1128 let result = async {
1129 let user = viewer.ok_or(AppError::Forbidden)?;
1130 let issue = state
1131 .store
1132 .issue_by_id(&id)
1133 .await?
1134 .ok_or(AppError::NotFound)?;
1135 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
1136 if access < auth::Access::Write {
1137 return Err(AppError::Forbidden);
1138 }
1139 // Only accept labels that belong to this repo.
1140 let repo_labels = state.store.list_labels(&repo.id).await?;
1141 let valid: std::collections::HashSet<&str> =
1142 repo_labels.iter().map(|l| l.id.as_str()).collect();
1143 let applied = state.store.issue_labels(&issue.id).await?;
1144 // Remove those unchecked, add those newly checked.
1145 for l in &applied {
1146 if !chosen.iter().any(|c| c == &l.id) {
1147 state.store.remove_issue_label(&issue.id, &l.id).await?;
1148 }
1149 }
1150 for c in &chosen {
1151 if valid.contains(c.as_str()) && !applied.iter().any(|l| &l.id == c) {
1152 state.store.add_issue_label(&issue.id, c).await?;
1153 }
1154 }
1155 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
1156 }
1157 .await;
1158 respond_redirect(result)
1159}
1160
1161/// Add-dependency form: the number of the blocking issue/PR.
1162#[derive(Debug, Deserialize)]
1163pub struct DepAddForm {
1164 /// The number of the issue/PR this one depends on.
1165 #[serde(default)]
1166 number: i64,
1167 /// CSRF token.
1168 #[serde(rename = "_csrf")]
1169 csrf: String,
1170}
1171
1172/// `POST /issue/{id}/deps/add` — mark this issue/PR blocked by another (Write).
1173pub async fn deps_add(
1174 State(state): State<AppState>,
1175 MaybeUser(viewer): MaybeUser,
1176 jar: CookieJar,
1177 headers: HeaderMap,
1178 Path(id): Path<String>,
1179 Form(form): Form<DepAddForm>,
1180) -> Response {
1181 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1182 return AppError::Forbidden.into_response();
1183 }
1184 let result = async {
1185 let user = viewer.ok_or(AppError::Forbidden)?;
1186 let issue = state
1187 .store
1188 .issue_by_id(&id)
1189 .await?
1190 .ok_or(AppError::NotFound)?;
1191 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
1192 if access < auth::Access::Write {
1193 return Err(AppError::Forbidden);
1194 }
1195 // Resolve the referenced number within the same repo (issue or PR).
1196 let target = state
1197 .store
1198 .issue_by_number_any(&repo.id, form.number)
1199 .await?
1200 .ok_or_else(|| AppError::BadRequest(format!("No issue or PR #{}.", form.number)))?;
1201 if target.id == issue.id {
1202 return Err(AppError::BadRequest(
1203 "An item cannot depend on itself.".to_string(),
1204 ));
1205 }
1206 state.store.add_dependency(&issue.id, &target.id).await?;
1207 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
1208 }
1209 .await;
1210 respond_redirect(result)
1211}
1212
1213/// Remove-dependency form: the depended-on issue id.
1214#[derive(Debug, Deserialize)]
1215pub struct DepRemoveForm {
1216 /// The `issues` id this one no longer depends on.
1217 #[serde(default)]
1218 depends_on: String,
1219 /// CSRF token.
1220 #[serde(rename = "_csrf")]
1221 csrf: String,
1222}
1223
1224/// `POST /issue/{id}/deps/remove` — drop a dependency edge (Write).
1225pub async fn deps_remove(
1226 State(state): State<AppState>,
1227 MaybeUser(viewer): MaybeUser,
1228 jar: CookieJar,
1229 headers: HeaderMap,
1230 Path(id): Path<String>,
1231 Form(form): Form<DepRemoveForm>,
1232) -> Response {
1233 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1234 return AppError::Forbidden.into_response();
1235 }
1236 let result = async {
1237 let user = viewer.ok_or(AppError::Forbidden)?;
1238 let issue = state
1239 .store
1240 .issue_by_id(&id)
1241 .await?
1242 .ok_or(AppError::NotFound)?;
1243 let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?;
1244 if access < auth::Access::Write {
1245 return Err(AppError::Forbidden);
1246 }
1247 state
1248 .store
1249 .remove_dependency(&issue.id, &form.depends_on)
1250 .await?;
1251 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
1252 }
1253 .await;
1254 respond_redirect(result)
1255}
1256
1257/// Redirect on success, render the error otherwise.
1258pub(crate) fn respond_redirect(result: AppResult<String>) -> Response {
1259 match result {
1260 Ok(dest) => Redirect::to(&dest).into_response(),
1261 Err(err) => err.into_response(),
1262 }
1263}