OXYGENHUB

Coverage / crates/hub/src/web/projects.rs 90.1%

684 of 759 lines run, 94 of 210 functions · run #531 on main · run missed

1
//! Projects (hub spec §8.2): boards of issues, proposals and notes across
2
//! the repositories of one person or organisation.
3
4
use super::settings::csrf;
5
use super::{filters, Error, Page, Shell};
6
use crate::auth::{roles, Access, Viewer};
7
use crate::db::{self, issues as idb, projects as pdb};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, Query, State};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use serde::Deserialize;
13
14
/// What a caller may do with an owner's projects.
15
pub struct Rights {
16
    /// Sees private projects.
17
    pub member: bool,
18
    /// Creates and changes projects.
19
    pub edit: bool,
20
}
21
22
/// Rights of `account` (anonymous when `None`) over `owner`'s projects;
23
/// `None` when there is no such person or organisation.
24275×
pub async fn rights_of(app: &Shared, account: Option<&db::accounts::Account>, owner: &str) -> sqlx::Result<Option<Rights>> {
25275×
    let admin = account.is_some_and(|a| a.is_admin);
26275×
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await? {
27270×
        let me = account.is_some_and(|v| v.id == a.id);
28270×
        return Ok(Some(Rights { member: me || admin, edit: me || admin }));
295×
    }
305×
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await? {
313×
        let member = match account {
323×
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await?.is_some(),
330×
            None => false,
34
        };
353×
        return Ok(Some(Rights { member: member || admin, edit: member || admin }));
362×
    }
372×
    Ok(None)
38275×
}
39
40116×
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
41116×
    rights_of(app, viewer.account(), owner)
42116×
        .await
43116×
        .map_err(|e| Error::from_db(shell.clone(), e))?
44116×
        .ok_or_else(|| Error::not_found(shell.clone()))
45116×
}
46
47
#[derive(Template)]
48
#[template(path = "projects.html")]
49
pub struct ProjectsPage {
50
    shell: Shell,
51
    owner: String,
52
    /// The projects shown: open or closed as `closed` says, matching `q`.
53
    projects: Vec<pdb::Project>,
54
    can_edit: bool,
55
    error: Option<String>,
56
    /// On your own page, the organisations you belong to: their boards
57
    /// are one click away, and where a board for one is made (#119).
58
    orgs: Vec<String>,
59
    /// The title filter, and whether the closed ones are shown (#124).
60
    q: String,
61
    closed: bool,
62
    open_count: usize,
63
    closed_count: usize,
64
}
65
66
impl ProjectsPage {
67
    /// The page for `all` the projects the viewer may see: counted open
68
    /// and closed among those matching `q`, then the ones asked for.
69
    #[allow(clippy::too_many_arguments)]
7013×
    fn build(
7113×
        shell: Shell,
7213×
        owner: String,
7313×
        all: Vec<pdb::Project>,
7413×
        can_edit: bool,
7513×
        error: Option<String>,
7613×
        orgs: Vec<String>,
7713×
        q: Option<String>,
7813×
        closed: bool,
7913×
    ) -> ProjectsPage {
8013×
        let q = q.unwrap_or_default().trim().to_string();
8113×
        let needle = q.to_lowercase();
8213×
        let matching: Vec<pdb::Project> = all
8313×
            .into_iter()
8413×
            .filter(|p| needle.is_empty() || p.title.to_lowercase().contains(&needle) || p.description.to_lowercase().contains(&needle))
8513×
            .collect();
8613×
        let closed_count = matching.iter().filter(|p| p.closed).count();
8713×
        let open_count = matching.len() - closed_count;
8813×
        let projects = matching.into_iter().filter(|p| p.closed == closed).collect();
8913×
        ProjectsPage { shell, owner, projects, can_edit, error, orgs, q, closed, open_count, closed_count }
9013×
    }
91
}
92
93
/// `?q=TEXT&state=open|closed` on the Projects page (#124).
94
#[derive(Deserialize, Default)]
95
pub struct ProjectsQuery {
96
    #[serde(default)]
97
    q: Option<String>,
98
    #[serde(default)]
99
    state: Option<String>,
100
}
101
102
/// The organisations the viewer belongs to, when `owner` is the viewer.
10313×
async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec<String> {
10413×
    match viewer.account() {
10512×
        Some(a) if a.username == owner => {
10611×
            crate::db::accounts::orgs_of(&app.db, a.id).await.map(|v| v.into_iter().map(|(o, _)| o.name).collect()).unwrap_or_default()
107
        }
1082×
        _ => vec![],
109
    }
11013×
}
111
11214×
pub async fn list(
11314×
    State(app): State<Shared>,
11414×
    viewer: Viewer,
11514×
    Path(owner): Path<String>,
11614×
    Query(query): Query<ProjectsQuery>,
11714×
) -> Result<Page<ProjectsPage>, Error> {
11814×
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
11914×
    let r = rights(&app, &shell, &viewer, &owner).await?;
12013×
    let projects = pdb::of_owner(&app.db, &owner)
12113×
        .await
12213×
        .map_err(|e| Error::from_db(shell.clone(), e))?
12313×
        .into_iter()
12413×
        .filter(|p| p.public || r.member)
12513×
        .collect();
12613×
    let orgs = own_orgs(&app, &viewer, &owner).await;
12713×
    let closed = query.state.as_deref() == Some("closed");
12813×
    Ok(Page(ProjectsPage::build(shell, owner, projects, r.edit, None, orgs, query.q, closed)))
12914×
}
130
131
#[derive(Deserialize)]
132
pub struct NewProjectForm {
133
    #[serde(rename = "_csrf")]
134
    csrf_token: String,
135
    title: String,
136
    #[serde(default)]
137
    description: String,
138
    #[serde(default)]
139
    private: Option<String>,
140
}
141
1428×
pub async fn create(
1438×
    State(app): State<Shared>,
1448×
    viewer: Viewer,
1458×
    Path(owner): Path<String>,
1468×
    Form(f): Form<NewProjectForm>,
1478×
) -> Result<Response, Error> {
1488×
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
1498×
    csrf(&viewer, &f.csrf_token, &shell)?;
1508×
    let r = rights(&app, &shell, &viewer, &owner).await?;
1518×
    if !r.edit {
1520×
        return Err(Error::not_found(shell));
1538×
    }
1548×
    let title = f.title.trim();
1558×
    if title.is_empty() || title.len() > 100 {
1560×
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1570×
        let orgs = own_orgs(&app, &viewer, &owner).await;
1580×
        return Ok(Page(ProjectsPage::build(
1590×
            shell,
1600×
            owner,
1610×
            projects,
1620×
            true,
1630×
            Some("A project needs a title of up to 100 characters.".into()),
1640×
            orgs,
1650×
            None,
1660×
            false,
1670×
        ))
1680×
        .into_response());
1698×
    }
1708×
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
1718×
        .await
1728×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
1738×
    db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
1748×
    Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response())
1758×
}
176
177
/// A card as the board shows it.
178
pub struct CardView {
179
    pub card: pdb::Card,
180
    /// `owner/name#N`, for issue and proposal cards.
181
    pub reference: String,
182
    pub url: String,
183
    pub title: String,
184
    /// `open`, `closed`, `landed`, `draft`… for issues and proposals.
185
    pub state: String,
186
    /// The issue's or proposal's labels, and an issue's assignees (#293).
187
    pub labels: Vec<idb::Label>,
188
    pub assignees: Vec<String>,
189
}
190
191
pub struct ColumnView {
192
    pub column: pdb::Column,
193
    pub cards: Vec<CardView>,
194
    /// Cards the page leaves out (#293): the oldest of a long column, or
195
    /// those a filter does not match; `total` is how many it holds.
196
    pub hidden: usize,
197
    pub total: usize,
198
    /// Its archived cards (#293), shown on the archive view.
199
    pub archived: usize,
200
    /// Its cards are all finished work (a Done column): its note says
201
    /// they are archived after a fortnight unless it says otherwise.
202
    pub finished: bool,
203
}
204
205
/// A column longer than this shows its newest cards only, with "show all"
206
/// (#293): a board's Done column otherwise grows without end.
207
pub const COLUMN_SHOWN: usize = 30;
208
209
/// How the board page shows its cards (#293).
210
#[derive(Deserialize, Default)]
211
pub struct BoardQuery {
212
    /// The column shown whole.
213
    #[serde(default)]
214
    all: Option<i64>,
215
    /// Only cards whose title or reference holds these words, and with
216
    /// `label:NAME`, `assignee:NAME` or `state:STATE` as the issue list takes.
217
    #[serde(default)]
218
    q: Option<String>,
219
    /// Only cards in this state (`open`, `closed`, `landed`…).
220
    #[serde(default)]
221
    state: Option<String>,
222
    /// The archived cards instead (`?archived=1`).
223
    #[serde(default)]
224
    archived: Option<String>,
225
    /// A past iteration: the cards archived under it, and its burn-up (#343).
226
    #[serde(default)]
227
    iteration: Option<i64>,
228
}
229
230
impl BoardQuery {
231104×
    fn text(&self) -> String {
232104×
        self.q.as_deref().map(str::trim).unwrap_or("").to_string()
233104×
    }
234104×
    fn state(&self) -> String {
235104×
        self.state.as_deref().map(str::trim).unwrap_or("").to_ascii_lowercase()
236104×
    }
23753×
    fn archived(&self) -> bool {
23853×
        self.archived.as_deref().is_some_and(|a| !a.is_empty() && a != "0")
23953×
    }
240
}
241
242
/// The board filter's `label:`, `assignee:` and `state:` terms, and its words.
243
#[derive(Default)]
244
struct CardFilter {
245
    labels: Vec<String>,
246
    assignees: Vec<String>,
247
    state: String,
248
    words: Vec<String>,
249
}
250
251
impl CardFilter {
25252×
    fn of(view: &BoardQuery) -> CardFilter {
25352×
        let mut f = CardFilter { state: view.state(), ..Default::default() };
25452×
        for tok in view.text().split_whitespace() {
2559×
            match tok.split_once(':') {
2566×
                Some(("label", v)) if !v.is_empty() => f.labels.push(v.to_lowercase()),
2572×
                Some(("assignee", v)) if !v.is_empty() => f.assignees.push(v.trim_start_matches('@').to_lowercase()),
2581×
                Some(("state", v)) if !v.is_empty() => f.state = v.to_lowercase(),
2593×
                _ => f.words.push(tok.to_lowercase()),
260
            }
261
        }
26252×
        f
26352×
    }
264
265150×
    fn is_empty(&self) -> bool {
266150×
        self.labels.is_empty() && self.assignees.is_empty() && self.state.is_empty() && self.words.is_empty()
267150×
    }
268
269
    /// Every term holds: each word in the title or reference, each label
270
    /// and assignee among the card's, and its state.
27186×
    fn keeps(&self, c: &CardView) -> bool {
27286×
        let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase());
27386×
        self.words.iter().all(|w| title.contains(w) || reference.contains(w))
27450×
            && self.labels.iter().all(|l| c.labels.iter().any(|x| x.name.eq_ignore_ascii_case(l)))
27547×
            && self.assignees.iter().all(|a| c.assignees.iter().any(|x| x.eq_ignore_ascii_case(a)))
27645×
            && (self.state.is_empty() || c.state.eq_ignore_ascii_case(&self.state))
27786×
    }
278
}
279
280
/// Filter and cut the columns as `view` says.
28152×
fn shape(columns: &mut [ColumnView], view: &BoardQuery) {
28252×
    let filter = CardFilter::of(view);
283150×
    for col in columns.iter_mut() {
284150×
        col.total = col.cards.len();
285150×
        if !filter.is_empty() {
28686×
            col.cards.retain(|c| filter.keeps(c));
287126×
        } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() && view.iteration.is_none() {
2881×
            // Cards join a column at its end: the newest are the last.
2891×
            let cut = col.cards.len() - COLUMN_SHOWN;
2901×
            col.cards.drain(..cut);
291125×
        }
292150×
        col.hidden = col.total - col.cards.len();
293
    }
29452×
}
295
296
/// An iteration as the board shows it (#343).
297
pub struct IterationView {
298
    pub it: pdb::Iteration,
299
    /// Its first and last days, YYYY-MM-DD.
300
    pub first: String,
301
    pub last: String,
302
    pub days: i64,
303
    /// Its days so far (all of them once it has ended).
304
    pub so_far: usize,
305
    /// Cards done in it so far.
306
    pub done: usize,
307
    /// The burn-up as SVG polyline points in a [`BURN_W`] × [`BURN_H`] box,
308
    /// one point per day so far; `top` is the count at the top of the box.
309
    pub points: String,
310
    pub top: usize,
311
}
312
313
pub const BURN_W: i64 = 280;
314
pub const BURN_H: i64 = 64;
315
316
/// How many past iterations the board lists.
317
const PAST_SHOWN: usize = 12;
318
319
const DAY_MS: i64 = 86_400_000;
320
321
/// `YYYY-MM-DD` to the midnight (UTC) that starts it.
3228×
fn day_ms(s: &str) -> Option<i64> {
32322×
    let mut parts = s.trim().splitn(3, '-').map(|p| p.parse::<i32>().ok());
3248×
    let (y, m, d) = (parts.next()??, parts.next()??, parts.next()??);
3257×
    let month = time::Month::try_from(u8::try_from(m).ok()?).ok()?;
3267×
    let date = time::Date::from_calendar_date(y, month, u8::try_from(d).ok()?).ok()?;
3277×
    Some(date.midnight().assume_utc().unix_timestamp() * 1000)
3288×
}
329
330
/// The day (UTC) holding `ms`, as `YYYY-MM-DD`.
331126×
fn day_of(ms: i64) -> String {
332126×
    let t = time::OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
333126×
    format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day())
334126×
}
335
33611×
async fn iteration_view(app: &Shared, it: pdb::Iteration, done: Option<i64>, now: i64) -> sqlx::Result<IterationView> {
33711×
    let times = pdb::done_times(&app.db, &it, done).await?;
33811×
    let days = pdb::iteration_days(&it);
33911×
    let up = pdb::burn_up(&times, it.start_ms, it.end_ms, it.closed_ms.unwrap_or(now));
34011×
    let top = up.last().copied().unwrap_or(0).max(1);
341
    // From nothing at the start to each day's end.
34211×
    let points = std::iter::once((0, 0))
34344×
        .chain(up.iter().enumerate().map(|(d, &n)| (d as i64 + 1, n)))
34455×
        .map(|(d, n)| format!("{},{}", d * BURN_W / days, BURN_H - n as i64 * BURN_H / top as i64))
34511×
        .collect::<Vec<_>>()
34611×
        .join(" ");
34711×
    Ok(IterationView {
34811×
        first: day_of(it.start_ms),
34911×
        last: day_of(it.end_ms - 1),
35011×
        days,
35111×
        so_far: up.len(),
35211×
        done: times.len(),
35311×
        points,
35411×
        top,
35511×
        it,
35611×
    })
35711×
}
358
359
#[derive(Template)]
360
#[template(path = "project.html")]
361
pub struct BoardPage {
362
    shell: Shell,
363
    project: pdb::Project,
364
    columns: Vec<ColumnView>,
365
    /// The owner's repositories the viewer can read, for adding cards.
366
    repos: Vec<String>,
367
    /// Changes cards and columns (an open project).
368
    can_edit: bool,
369
    /// Changes the settings (to reopen a closed project too).
370
    can_manage: bool,
371
    notice: Option<String>,
372
    error: Option<String>,
373
    /// Owners the viewer may move the project to (#125): themselves and the
374
    /// organisations they belong to, but not its owner now.
375
    transfer_to: Vec<String>,
376
    /// The filter the page shows (#293), and whether one is set.
377
    q: String,
378
    state: String,
379
    filtered: bool,
380
    /// The archive view: archived cards, each restorable (#293).
381
    archived: bool,
382
    /// Archived cards on the whole board.
383
    archived_total: usize,
384
    /// The open iteration, the past ones (newest first), and the one the
385
    /// page shows the cards of (#343).
386
    current: Option<IterationView>,
387
    past: Vec<IterationView>,
388
    shown_iteration: Option<IterationView>,
389
    /// A new iteration's suggested first and last days: today and 13 days on.
390
    next_first: String,
391
    next_last: String,
392
}
393
394
#[allow(clippy::too_many_arguments)]
39554×
async fn board(
39654×
    app: &Shared,
39754×
    shell: Shell,
39854×
    viewer: &Viewer,
39954×
    owner: &str,
40054×
    number: i64,
40154×
    notice: Option<String>,
40254×
    error: Option<String>,
40354×
    view: &BoardQuery,
40454×
) -> Result<Page<BoardPage>, Error> {
40554×
    let e = |e| Error::from_db(shell.clone(), e);
40654×
    let r = rights(app, &shell, viewer, owner).await?;
40754×
    let project = pdb::get(&app.db, owner, number)
40854×
        .await
40954×
        .map_err(e)?
41054×
        .filter(|p| p.public || r.member)
41154×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
41252×
    let now = db::now_ms();
41352×
    let iterations = pdb::iterations(&app.db, project.id).await.map_err(e)?;
41452×
    let shown = view.iteration.and_then(|id| iterations.iter().find(|i| i.id == id && i.closed_ms.is_some()).cloned());
415
    // A past iteration's page is the archive of what was done in it.
41652×
    let archived = view.archived() || shown.is_some();
41752×
    let (mut columns, repos) = board_columns(app, viewer.account(), owner, &project, archived).await.map_err(e)?;
41852×
    if let Some(it) = &shown {
4196×
        for col in columns.iter_mut() {
4206×
            col.cards.retain(|c| c.card.iteration_id == Some(it.id));
421
        }
42250×
    }
42352×
    shape(&mut columns, view);
42452×
    let archived_total = columns.iter().map(|c| c.archived).sum();
425150×
    let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::<Vec<_>>());
42652×
    let (mut current, mut past) = (None, Vec::new());
42752×
    for it in iterations {
4289×
        if it.closed_ms.is_none() {
4293×
            current = Some(iteration_view(app, it, done, now).await.map_err(e)?);
4306×
        } else if past.len() < PAST_SHOWN {
4316×
            past.push(iteration_view(app, it, done, now).await.map_err(e)?);
4320×
        }
433
    }
43452×
    let shown_iteration = match shown {
4352×
        Some(it) => Some(iteration_view(app, it, done, now).await.map_err(e)?),
43650×
        None => None,
437
    };
43852×
    let can_edit = r.edit && !project.closed;
43952×
    let mut transfer_to = Vec::new();
44052×
    if let (true, Some(a)) = (r.edit, viewer.account()) {
44151×
        transfer_to.push(a.username.clone());
44251×
        let orgs = db::accounts::orgs_of(&app.db, a.id).await.map_err(e)?;
44351×
        transfer_to.extend(orgs.into_iter().map(|(o, _)| o.name));
44455×
        transfer_to.retain(|o| o != owner);
4451×
    }
44652×
    let (q, state) = (view.text(), view.state());
44752×
    let filtered = !q.is_empty() || !state.is_empty();
44852×
    Ok(Page(BoardPage {
44952×
        shell,
45052×
        project,
45152×
        columns,
45252×
        repos,
45352×
        can_edit,
45452×
        can_manage: r.edit,
45552×
        notice,
45652×
        error,
45752×
        transfer_to,
45852×
        q,
45952×
        state,
46052×
        filtered,
46152×
        archived,
46252×
        archived_total,
46352×
        current,
46452×
        past,
46552×
        shown_iteration,
46652×
        next_first: day_of(now),
46752×
        next_last: day_of(now + 13 * DAY_MS),
46852×
    }))
46954×
}
470
471
/// A project's columns with the cards `account` may see, and the owner's
472
/// repositories it can read (for adding cards). `archived`: the archived
473
/// cards instead of those on the board (#293); each column counts its
474
/// archived cards either way.
47573×
pub async fn board_columns(
47673×
    app: &Shared,
47773×
    account: Option<&db::accounts::Account>,
47873×
    owner: &str,
47973×
    project: &pdb::Project,
48073×
    archived: bool,
48173×
) -> sqlx::Result<(Vec<ColumnView>, Vec<String>)> {
48273×
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
48373×
    for row in db::repos::list_by_owner(&app.db, owner).await? {
48467×
        let role = roles::role_for(&app.db, account, &row).await?;
48567×
        if roles::allows(role, Access::Read) {
48659×
            readable.insert(row.id, row);
48759×
        }
488
    }
48973×
    let all = pdb::cards(&app.db, project.id).await?;
4901041×
    let archived_in = |column: i64| all.iter().filter(|c| c.column_id == column && c.archived_ms.is_some()).count();
491353×
    let cards: Vec<&pdb::Card> = all.iter().filter(|c| c.archived_ms.is_some() == archived).collect();
492
    // What the cards show, read once per repository, not per card: a
493
    // board of 300 cards made 300 queries.
49473×
    let mut shown: std::collections::HashMap<(i64, bool, i64), Shown> = Default::default();
49573×
    let mut repo_ids: Vec<(i64, bool)> =
496318×
        cards.iter().filter_map(|c| Some((c.repo_id?, c.kind == "issue"))).filter(|(rid, _)| readable.contains_key(rid)).collect();
49773×
    repo_ids.sort();
49873×
    repo_ids.dedup();
49973×
    for (rid, issues) in repo_ids {
500110×
        shown.extend(shown_in(app, rid, issues).await?.into_iter().map(|(n, s)| ((rid, issues, n), s)));
501
    }
50273×
    let mut columns = Vec::new();
503213×
    for column in pdb::columns(&app.db, project.id).await? {
504213×
        let mut views = Vec::new();
505936×
        for card in cards.iter().copied().filter(|c| c.column_id == column.id) {
506318×
            match (card.kind.as_str(), card.repo_id, card.number) {
507318×
                ("note", _, _) => views.push(CardView {
508228×
                    card: card.clone(),
509228×
                    reference: String::new(),
510228×
                    url: String::new(),
511228×
                    title: card.note.clone(),
512228×
                    state: String::new(),
513228×
                    labels: Vec::new(),
514228×
                    assignees: Vec::new(),
515228×
                }),
51690×
                (kind, Some(rid), Some(n)) => {
517
                    // Cards of repositories the viewer cannot read are not shown at all.
51890×
                    let Some(row) = readable.get(&rid) else { continue };
51989×
                    let Some(s) = shown.get(&(rid, kind == "issue", n)) else { continue };
52089×
                    let slug = format!("{}/{}", row.owner, row.name);
52189×
                    views.push(CardView {
52289×
                        card: card.clone(),
52389×
                        reference: format!("{slug}#{n}"),
52489×
                        url: format!("/{slug}/{kind}/{n}"),
52589×
                        title: s.title.clone(),
52689×
                        state: s.state.clone(),
52789×
                        labels: s.labels.clone(),
52889×
                        assignees: s.assignees.clone(),
52989×
                    });
530
                }
5310×
                _ => {}
532
            }
533
        }
534213×
        let total = views.len();
535213×
        let archived = archived_in(column.id);
536317×
        let items: Vec<&CardView> = views.iter().filter(|v| v.card.kind != "note").collect();
537213×
        let finished = !items.is_empty() && items.iter().all(|v| matches!(v.state.as_str(), "closed" | "landed"));
538213×
        columns.push(ColumnView { column, cards: views, hidden: 0, total, archived, finished });
539
    }
54073×
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
54173×
    repos.sort();
54273×
    Ok((columns, repos))
54373×
}
544
545
/// What a card shows of an issue or a proposal.
546
struct Shown {
547
    title: String,
548
    state: String,
549
    labels: Vec<idb::Label>,
550
    assignees: Vec<String>,
551
}
552
553
/// A repository's issues (or proposals) as cards show them, by number.
55459×
async fn shown_in(app: &Shared, repo_id: i64, issues: bool) -> sqlx::Result<Vec<(i64, Shown)>> {
555
    use std::collections::HashMap;
55659×
    let mut out = Vec::new();
55759×
    if issues {
55848×
        let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new();
55948×
        for (id, l) in idb::labels_by_issue(&app.db, repo_id).await? {
56018×
            labels.entry(id).or_default().push(l);
56118×
        }
56248×
        let mut assignees: HashMap<i64, Vec<String>> = HashMap::new();
56348×
        for (id, u) in idb::assignees_by_issue(&app.db, repo_id).await? {
5646×
            assignees.entry(id).or_default().push(u);
5656×
        }
56699×
        for i in idb::list(&app.db, repo_id).await? {
56799×
            let mut a = assignees.remove(&i.id).unwrap_or_default();
56899×
            a.sort();
56999×
            let s = Shown { labels: labels.remove(&i.id).unwrap_or_default(), assignees: a, title: i.title, state: i.state };
57099×
            out.push((i.number, s));
57199×
        }
572
    } else {
57311×
        let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new();
57411×
        for (id, l) in idb::labels_by_proposal(&app.db, repo_id).await? {
5750×
            labels.entry(id).or_default().push(l);
5760×
        }
57711×
        for p in db::proposals::list(&app.db, repo_id, &[]).await? {
57811×
            let s = Shown { labels: labels.remove(&p.id).unwrap_or_default(), assignees: Vec::new(), title: p.title, state: p.state };
57911×
            out.push((p.number, s));
58011×
        }
581
    }
58259×
    Ok(out)
58359×
}
584
58534×
pub async fn show(
58634×
    State(app): State<Shared>,
58734×
    viewer: Viewer,
58834×
    Path((owner, number)): Path<(String, i64)>,
58934×
    Query(view): Query<BoardQuery>,
59034×
) -> Result<Response, Error> {
59134×
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
592
    // A board transferred away (#125): its old address goes to its new one.
59334×
    let e = |e| Error::from_db(shell.clone(), e);
59434×
    if pdb::get(&app.db, &owner, number).await.map_err(e)?.is_none() {
5952×
        if let Some(p) = pdb::redirected(&app.db, &owner, number).await.map_err(e)? {
5961×
            return Ok(Redirect::permanent(&format!("/projects/{}/{}", p.owner, p.number)).into_response());
5971×
        }
59832×
    }
59933×
    Ok(board(&app, shell, &viewer, &owner, number, None, None, &view).await?.into_response())
60034×
}
601
602
#[derive(Deserialize)]
603
pub struct BoardForm {
604
    #[serde(rename = "_csrf")]
605
    csrf_token: String,
606
    action: String,
607
    #[serde(default)]
608
    card: Option<i64>,
609
    #[serde(default)]
610
    column: Option<i64>,
611
    #[serde(default)]
612
    name: String,
613
    /// `repo#12`, or `#12` with `repo`.
614
    #[serde(default)]
615
    reference: String,
616
    #[serde(default)]
617
    repo: String,
618
    #[serde(default)]
619
    note: String,
620
    /// Automatic archiving: `finished` (the default), `days` or `never`.
621
    #[serde(default)]
622
    mode: String,
623
    #[serde(default)]
624
    title: String,
625
    #[serde(default)]
626
    description: String,
627
    #[serde(default)]
628
    private: Option<String>,
629
    #[serde(default)]
630
    closed: Option<String>,
631
    /// The owner to transfer the project to (#125).
632
    #[serde(default)]
633
    to: String,
634
    /// Days, for archiving a column's older cards (#293); empty is never.
635
    #[serde(default)]
636
    days: String,
637
    /// The iteration to close, and a new one's first and last days as
638
    /// YYYY-MM-DD (#343).
639
    #[serde(default)]
640
    iteration: Option<i64>,
641
    #[serde(default)]
642
    start: String,
643
    #[serde(default)]
644
    end: String,
645
}
646
647
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
648
/// `repo#12`, `#12` with `repo`, or `owner/repo#12` when `owner` is the
649
/// board's (the form the board itself shows, oxygenhub#118). `Err` says
650
/// what is wrong.
65126×
fn parse_reference(reference: &str, repo: &str, owner: &str) -> Result<(String, i64), String> {
65226×
    let bad = || "Name an issue or proposal as repo#12, or write a note.".to_string();
65326×
    let r = reference.trim();
65426×
    let (name, n) = r.split_once('#').ok_or_else(bad)?;
65525×
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
65625×
    let name = match name.split_once('/') {
6574×
        Some((o, rest)) if o.eq_ignore_ascii_case(owner) => rest,
6582×
        Some((o, _)) => return Err(format!("This board holds only {owner}'s issues and proposals, not {o}'s.")),
65921×
        None => name,
660
    };
66123×
    let n: i64 = n.trim().parse().map_err(|_| bad())?;
66223×
    if name.is_empty() || n <= 0 || name.contains('/') {
6632×
        return Err(bad());
66421×
    }
66521×
    Ok((name.to_string(), n))
66626×
}
667
668
/// Add a note, or the issue or proposal `reference` names (`repo#12`, or
669
/// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller
670
/// should be told: an unknown repository or number, or a card already there.
671
#[allow(clippy::too_many_arguments)]
67222×
pub async fn add_card(
67322×
    app: &Shared,
67422×
    account: Option<&db::accounts::Account>,
67522×
    owner: &str,
67622×
    project_id: i64,
67722×
    column_id: i64,
67822×
    reference: &str,
67922×
    repo: &str,
68022×
    note: &str,
68122×
    actor: &str,
68222×
) -> sqlx::Result<Result<pdb::Card, String>> {
68322×
    if !note.trim().is_empty() {
6844×
        let note: String = note.trim().chars().take(500).collect();
6854×
        pdb::add_card(
6864×
            &app.db,
6874×
            project_id,
6884×
            pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: actor },
6894×
        )
6904×
        .await?;
6914×
        return Ok(Ok(newest_card(app, project_id).await?));
69218×
    }
69318×
    let (repo, n) = match parse_reference(reference, repo, owner) {
69417×
        Ok(r) => r,
6951×
        Err(why) => return Ok(Err(why)),
696
    };
69717×
    let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) };
69816×
    let role = roles::role_for(&app.db, account, &row).await?;
69916×
    let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?;
70016×
    let kind = if issues.contains(&n) {
70113×
        "issue"
7023×
    } else if proposals.contains(&n) {
7032×
        "proposal"
704
    } else {
7051×
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
706
    };
70715×
    if !roles::allows(role, Access::Read) {
7080×
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
70915×
    }
71015×
    if !pdb::add_card(
71115×
        &app.db,
71215×
        project_id,
71315×
        pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor },
71415×
    )
71515×
    .await?
716
    {
7174×
        return Ok(Err(format!("{repo}#{n} is already on this project.")));
71811×
    }
71911×
    Ok(Ok(newest_card(app, project_id).await?))
72022×
}
721
72215×
async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result<pdb::Card> {
72315×
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = $1 ORDER BY id DESC LIMIT 1").bind(project_id).fetch_one(&app.db).await
72415×
}
725
72638×
pub async fn act(
72738×
    State(app): State<Shared>,
72838×
    viewer: Viewer,
72938×
    Path((owner, number)): Path<(String, i64)>,
73038×
    Form(f): Form<BoardForm>,
73138×
) -> Result<Response, Error> {
73238×
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
73338×
    csrf(&viewer, &f.csrf_token, &shell)?;
73438×
    let r = rights(&app, &shell, &viewer, &owner).await?;
73538×
    let e = |e| Error::from_db(shell.clone(), e);
73638×
    let project = pdb::get(&app.db, &owner, number)
73738×
        .await
73838×
        .map_err(e)?
73938×
        .filter(|p| p.public || r.member)
74038×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
74138×
    if !r.edit {
7421×
        return Err(Error::not_found(shell));
74337×
    }
74437×
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete" | "transfer") {
7451×
        return Ok(board(
7461×
            &app,
7471×
            shell,
7481×
            &viewer,
7491×
            &owner,
7501×
            number,
7511×
            None,
7521×
            Some("This project is closed; reopen it to change it.".into()),
7531×
            &BoardQuery::default(),
7541×
        )
7551×
        .await?
7561×
        .into_response());
75736×
    }
75836×
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
75936×
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
76041×
    let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id));
76136×
    let mut notice = None;
76236×
    let mut error = None;
76336×
    match f.action.as_str() {
76436×
        "add" => {
76511×
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
7663×
            if let Err(msg) =
76711×
                add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)?
7683×
            {
7693×
                error = Some(msg);
7708×
            }
771
        }
77225×
        "move" => {
7731×
            if let (Some(card), Some(col)) = (f.card, f.column) {
7741×
                pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?;
7750×
            }
776
        }
77724×
        "up" | "down" => {
7780×
            if let Some(card) = f.card {
7790×
                pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?;
7800×
            }
781
        }
78224×
        "remove" => {
7830×
            if let Some(card) = f.card {
7840×
                pdb::remove_card(&app.db, project.id, card).await.map_err(e)?;
7850×
            }
786
        }
78724×
        "archive" => {
7881×
            if let Some(card) = f.card {
7891×
                pdb::archive_cards(&app.db, project.id, &[card]).await.map_err(e)?;
7900×
            }
791
        }
79223×
        "restore" => {
7932×
            if let Some(card) = f.card {
7942×
                pdb::restore_card(&app.db, project.id, card).await.map_err(e)?;
7950×
            }
7962×
            return Ok(Redirect::to(&format!("/projects/{owner}/{number}?archived=1")).into_response());
797
        }
79821×
        "takes_finished" => {
7992×
            let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) };
8005×
            let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default();
8012×
            let on = f.mode == "on";
8022×
            pdb::set_takes_finished(&app.db, project.id, on.then_some(col)).await.map_err(e)?;
8032×
            notice = Some(if on {
8041×
                format!("Cards move to {name} when their issue closes or their proposal lands or closes.")
805
            } else {
8061×
                "Cards stay where they are when their issue closes or their proposal lands.".into()
807
            });
808
        }
80919×
        "archive_older" | "archive_after" => {
8105×
            let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) };
8115×
            let days = match (f.action.as_str(), f.mode.as_str(), f.days.trim()) {
8125×
                ("archive_after", "finished", _) | ("archive_after", "", "") => None,
8131×
                ("archive_after", "never", _) => Some(pdb::NEVER),
8143×
                (_, _, d) => match d.parse::<i64>() {
8152×
                    Ok(n) if (0..=3650).contains(&n) => Some(n),
816
                    _ => {
8171×
                        error = Some("Give a number of days, 0 to 3650.".into());
8181×
                        None
819
                    }
820
                },
821
            };
82215×
            let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default();
8235×
            if error.is_none() && f.action == "archive_after" {
8243×
                pdb::set_archive_after(&app.db, project.id, col, days).await.map_err(e)?;
8253×
                notice = Some(match days {
8261×
                    Some(pdb::NEVER) => format!("{name} keeps its cards until you archive them."),
8271×
                    Some(n) => format!("{name} archives its cards {n} day{} after they reach it.", if n == 1 { "" } else { "s" }),
8281×
                    None => format!("{name} archives finished cards {} days after they reach it.", pdb::FINISHED_KEPT_DAYS),
829
                });
8302×
            } else if let (None, Some(days)) = (&error, days) {
8311×
                let now = db::now_ms();
8321×
                let ids: Vec<i64> = pdb::cards(&app.db, project.id)
8331×
                    .await
8341×
                    .map_err(e)?
8351×
                    .into_iter()
8364×
                    .filter(|c| c.column_id == col && c.archived_ms.is_none() && pdb::older_than(c, days, now))
8371×
                    .map(|c| c.id)
8381×
                    .collect();
8391×
                let n = pdb::archive_cards(&app.db, project.id, &ids).await.map_err(e)?;
8401×
                let detail = format!("{n} card(s) of {name} older than {days} days");
8411×
                db::accounts::audit(&app.db, viewer.account(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok();
8421×
                notice = Some(format!("Archived {n} card{} from {name}.", if n == 1 { "" } else { "s" }));
8431×
            }
844
        }
84514×
        "start_iteration" => {
8464×
            let name: String = f.name.trim().chars().take(60).collect();
8474×
            match (day_ms(&f.start), day_ms(&f.end)) {
8484×
                _ if name.is_empty() => error = Some("Name the iteration.".into()),
8493×
                (Some(start), Some(last)) if last >= start && last - start < 366 * DAY_MS => {
8502×
                    match pdb::start_iteration(&app.db, project.id, &name, start, last + DAY_MS).await.map_err(e)? {
8511×
                        Some(_) => notice = Some(format!("{name} started.")),
8521×
                        None => error = Some("Close the open iteration before starting another.".into()),
853
                    }
854
                }
8551×
                (Some(_), Some(_)) => error = Some("An iteration ends on or after its first day, within a year.".into()),
8561×
                _ => error = Some("Give the first and last days as dates.".into()),
857
            }
858
        }
85910×
        "close_iteration" => {
8602×
            let Some(id) = f.iteration else { return Err(Error::bad(shell, "no iteration")) };
8612×
            match pdb::close_iteration(&app.db, project.id, id, pdb::done_column(&columns)).await.map_err(e)? {
8621×
                Some(n) => {
8631×
                    let detail = format!("iteration {id}: {n} done card(s) archived");
8641×
                    db::accounts::audit(&app.db, viewer.account(), "project.iteration_closed", &format!("{owner}/{number}"), &detail)
8651×
                        .await
8661×
                        .ok();
8671×
                    notice = Some(format!("Closed: {n} done card{} archived under it.", if n == 1 { "" } else { "s" }));
868
                }
8691×
                None => error = Some("That iteration is not open.".into()),
870
            }
871
        }
8728×
        "add_column" => {
8730×
            let name = f.name.trim();
8740×
            if name.is_empty() || name.len() > 40 {
8750×
                error = Some("A column has a name of up to 40 characters.".into());
8760×
            } else {
8770×
                pdb::add_column(&app.db, project.id, name).await.map_err(e)?;
878
            }
879
        }
8808×
        "rename_column" => {
8810×
            if let (Some(col), name) = (f.column, f.name.trim()) {
8820×
                if !name.is_empty() && name.len() <= 40 {
8830×
                    pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?;
8840×
                }
8850×
            }
886
        }
8878×
        "left" | "right" => {
8880×
            if let Some(col) = f.column {
8890×
                pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?;
8900×
            }
891
        }
8928×
        "remove_column" => {
8933×
            if let Some(col) = f.column {
8943×
                if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? {
8951×
                    error = Some("A project keeps at least one column.".into());
8962×
                }
8970×
            }
898
        }
8995×
        "settings" => {
9002×
            let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() };
9012×
            pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?;
9022×
            notice = Some("Saved.".into());
903
        }
9043×
        "transfer" => {
9052×
            let to = f.to.trim();
9062×
            match rights_of(&app, viewer.account(), to).await.map_err(e)? {
9072×
                _ if to == owner => error = Some(format!("It is {owner}'s already.")),
9081×
                Some(t) if t.edit => {
9091×
                    let moved = pdb::transfer(&app.db, project.id, to).await.map_err(e)?;
9101×
                    let detail = format!("{owner}/{number} to {to}/{}", moved.number);
9111×
                    db::accounts::audit(&app.db, viewer.account(), "project.transferred", &detail, &project.title).await.ok();
9121×
                    return Ok(Redirect::to(&format!("/projects/{to}/{}", moved.number)).into_response());
913
                }
9141×
                _ => error = Some(format!("You cannot make projects under {to}, so you cannot move this one there.")),
915
            }
916
        }
9171×
        "delete" => {
9181×
            pdb::delete(&app.db, project.id).await.map_err(e)?;
9191×
            db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok();
9201×
            return Ok(Redirect::to(&format!("/projects/{owner}")).into_response());
921
        }
9220×
        _ => return Err(Error::bad(shell, "unknown action")),
923
    }
92432×
    if notice.is_none() && error.is_none() {
925
        // Back to the board, so a reload does not repeat the action.
92612×
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
92720×
    }
92820×
    Ok(board(&app, shell, &viewer, &owner, number, notice, error, &BoardQuery::default()).await?.into_response())
92938×
}
930
931
/// The projects an issue or proposal is on that the viewer may see, with
932
/// their column.
933106×
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> {
934106×
    let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] };
935106×
    let mut out = Vec::new();
936106×
    for (p, col) in all {
9375×
        let visible = p.public
9381×
            || viewer.is_admin()
9391×
            || match viewer.account() {
9400×
                Some(v) if v.username == p.owner => true,
9410×
                Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() {
9420×
                    Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(),
9430×
                    None => false,
944
                },
9451×
                None => false,
946
            };
9475×
        if visible {
9484×
            out.push((p, col));
9494×
        }
950
    }
951106×
    out
952106×
}
953
954
#[cfg(test)]
955
mod tests {
956
    use super::parse_reference;
957
958
    #[test]
9591×
    fn references() {
9607×
        let p = |r: &str, repo: &str| parse_reference(r, repo, "andy").ok();
9611×
        assert_eq!(p("oxygen#12", ""), Some(("oxygen".into(), 12)));
9621×
        assert_eq!(p("#7", "hub"), Some(("hub".into(), 7)));
9631×
        assert_eq!(p(" hub # 3 ", ""), Some(("hub".into(), 3)));
9641×
        assert_eq!(p("#7", ""), None, "a repository is needed");
9651×
        assert_eq!(p("andy/hub#4", ""), Some(("hub".into(), 4)), "the board's own owner, as it shows cards");
9661×
        assert!(parse_reference("other/repo#1", "", "andy").unwrap_err().contains("only andy's"), "another owner is refused, saying why");
9671×
        assert_eq!(p("hub#0", ""), None);
9681×
        assert_eq!(p("hub", ""), None);
9691×
    }
970
}
971
972
#[derive(Deserialize)]
973
pub struct PlaceForm {
974
    #[serde(rename = "_csrf")]
975
    csrf_token: String,
976
    /// The project's number among its owner's.
977
    project: i64,
978
    /// `add` or `remove`.
979
    action: String,
980
}
981
982
/// Put an issue or proposal on one of its repository owner's projects (in
983
/// the first column), or take it off, from its own page.
9842×
async fn place(
9852×
    app: Shared,
9862×
    viewer: Viewer,
9872×
    owner: String,
9882×
    name: String,
9892×
    kind: &'static str,
9902×
    number: i64,
9912×
    f: PlaceForm,
9922×
) -> Result<Response, Error> {
9932×
    let page = format!("/{owner}/{name}/{kind}/{number}");
9942×
    let shell = Shell::new(&app, &page, &viewer);
9952×
    csrf(&viewer, &f.csrf_token, &shell)?;
9962×
    let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?;
9972×
    let r = rights(&app, &shell, &viewer, &row.owner).await?;
9982×
    if !r.edit {
9990×
        return Err(Error::not_found(shell));
10002×
    }
10012×
    let e = |e| Error::from_db(shell.clone(), e);
10022×
    let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
10032×
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
10042×
    let mut error = None;
10052×
    match f.action.as_str() {
10062×
        "add" => {
10071×
            let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
10081×
            let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) };
10090×
            if let Err(msg) =
10101×
                add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor)
10111×
                    .await
10121×
                    .map_err(e)?
10130×
            {
10140×
                error = Some(msg);
10151×
            }
1016
        }
10171×
        "remove" => {
10181×
            for c in pdb::cards(&app.db, project.id).await.map_err(e)? {
10191×
                if c.repo_id == Some(row.id) && c.number == Some(number) {
10201×
                    pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?;
10210×
                }
1022
            }
1023
        }
10240×
        _ => return Err(Error::bad(shell, "add or remove")),
1025
    }
10262×
    let to = match error {
10270×
        Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)),
10282×
        None => page,
1029
    };
10302×
    Ok(Redirect::to(&to).into_response())
10312×
}
1032
1033
/// `POST /{owner}/{name}/proposal/{number}/project`.
10342×
pub async fn place_proposal(
10352×
    State(app): State<Shared>,
10362×
    viewer: Viewer,
10372×
    Path((owner, name, number)): Path<(String, String, i64)>,
10382×
    Form(f): Form<PlaceForm>,
10392×
) -> Result<Response, Error> {
10402×
    place(app, viewer, owner, name, "proposal", number, f).await
10412×
}
1042
1043
/// `POST /{owner}/{name}/issue/{number}/project`.
10440×
pub async fn place_issue(
10450×
    State(app): State<Shared>,
10460×
    viewer: Viewer,
10470×
    Path((owner, name, number)): Path<(String, String, i64)>,
10480×
    Form(f): Form<PlaceForm>,
10490×
) -> Result<Response, Error> {
10500×
    place(app, viewer, owner, name, "issue", number, f).await
10510×
}
1052
1053
/// The owner's open projects `viewer` may add an item to that it is not on
1054
/// yet; `None` when the viewer may not change the owner's projects at all.
1055106×
pub async fn project_choices(
1056106×
    app: &Shared,
1057106×
    viewer: &Viewer,
1058106×
    repo: &db::repos::RepoRow,
1059106×
    placed: &[(pdb::Project, String)],
1060106×
) -> Option<Vec<pdb::Project>> {
1061106×
    let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None };
1062106×
    if !r.edit {
106345×
        return None;
106461×
    }
1065
    Some(
106661×
        pdb::of_owner(&app.db, &repo.owner)
106761×
            .await
106861×
            .unwrap_or_default()
106961×
            .into_iter()
107061×
            .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id))
107161×
            .collect(),
1072
    )
1073106×
}