OXYGENHUB
◆ v0.4.1 oxygenhub / crates / hub / src / web / projects.rs
23.8 KiB · Rust · last changed The store as its provider's icon; a tidier Projects page 25 days ago source blame history raw
e1ec9fbf Andy McCall ↶ 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, projects as pdb};
8
use crate::Shared;
9
use askama::Template;
7b26e611 Andy McCall ↶ 10
use axum::extract::{Form, Path, Query, State};
e1ec9fbf Andy McCall ↶ 11
use axum::response::{IntoResponse, Redirect, Response};
12
use serde::Deserialize;
13
ef20fdc1 Andy McCall ↶ 14
/// What a caller may do with an owner's projects.
15
pub struct Rights {
e1ec9fbf Andy McCall ↶ 16
    /// Sees private projects.
ef20fdc1 Andy McCall ↶ 17
    pub member: bool,
e1ec9fbf Andy McCall ↶ 18
    /// Creates and changes projects.
ef20fdc1 Andy McCall ↶ 19
    pub edit: bool,
e1ec9fbf Andy McCall ↶ 20
}
21
ef20fdc1 Andy McCall ↶ 22
/// Rights of `account` (anonymous when `None`) over `owner`'s projects;
23
/// `None` when there is no such person or organisation.
24
pub async fn rights_of(app: &Shared, account: Option<&db::accounts::Account>, owner: &str) -> sqlx::Result<Option<Rights>> {
25
    let admin = account.is_some_and(|a| a.is_admin);
26
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await? {
27
        let me = account.is_some_and(|v| v.id == a.id);
28
        return Ok(Some(Rights { member: me || admin, edit: me || admin }));
e1ec9fbf Andy McCall ↶ 29
    }
ef20fdc1 Andy McCall ↶ 30
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await? {
31
        let member = match account {
32
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await?.is_some(),
e1ec9fbf Andy McCall ↶ 33
            None => false,
34
        };
ef20fdc1 Andy McCall ↶ 35
        return Ok(Some(Rights { member: member || admin, edit: member || admin }));
e1ec9fbf Andy McCall ↶ 36
    }
ef20fdc1 Andy McCall ↶ 37
    Ok(None)
38
}
39
40
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
41
    rights_of(app, viewer.account(), owner)
42
        .await
43
        .map_err(|e| Error::from_db(shell.clone(), e))?
44
        .ok_or_else(|| Error::not_found(shell.clone()))
e1ec9fbf Andy McCall ↶ 45
}
46
47
#[derive(Template)]
48
#[template(path = "projects.html")]
49
pub struct ProjectsPage {
50
    shell: Shell,
51
    owner: String,
7b26e611 Andy McCall ↶ 52
    /// The projects shown: open or closed as `closed` says, matching `q`.
e1ec9fbf Andy McCall ↶ 53
    projects: Vec<pdb::Project>,
54
    can_edit: bool,
55
    error: Option<String>,
a024e800 Andy McCall ↶ 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>,
7b26e611 Andy McCall ↶ 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,
e1ec9fbf Andy McCall ↶ 64
}
65
7b26e611 Andy McCall ↶ 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)]
70
    fn build(
71
        shell: Shell,
72
        owner: String,
73
        all: Vec<pdb::Project>,
74
        can_edit: bool,
75
        error: Option<String>,
76
        orgs: Vec<String>,
77
        q: Option<String>,
78
        closed: bool,
79
    ) -> ProjectsPage {
80
        let q = q.unwrap_or_default().trim().to_string();
81
        let needle = q.to_lowercase();
82
        let matching: Vec<pdb::Project> = all
83
            .into_iter()
84
            .filter(|p| needle.is_empty() || p.title.to_lowercase().contains(&needle) || p.description.to_lowercase().contains(&needle))
85
            .collect();
86
        let closed_count = matching.iter().filter(|p| p.closed).count();
87
        let open_count = matching.len() - closed_count;
88
        let projects = matching.into_iter().filter(|p| p.closed == closed).collect();
89
        ProjectsPage { shell, owner, projects, can_edit, error, orgs, q, closed, open_count, closed_count }
90
    }
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
a024e800 Andy McCall ↶ 102
/// The organisations the viewer belongs to, when `owner` is the viewer.
103
async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec<String> {
104
    match viewer.account() {
105
        Some(a) if a.username == owner => {
106
            crate::db::accounts::orgs_of(&app.db, a.id).await.map(|v| v.into_iter().map(|(o, _)| o.name).collect()).unwrap_or_default()
107
        }
108
        _ => vec![],
109
    }
110
}
111
7b26e611 Andy McCall ↶ 112
pub async fn list(
113
    State(app): State<Shared>,
114
    viewer: Viewer,
115
    Path(owner): Path<String>,
116
    Query(query): Query<ProjectsQuery>,
117
) -> Result<Page<ProjectsPage>, Error> {
e1ec9fbf Andy McCall ↶ 118
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
119
    let r = rights(&app, &shell, &viewer, &owner).await?;
120
    let projects = pdb::of_owner(&app.db, &owner)
121
        .await
122
        .map_err(|e| Error::from_db(shell.clone(), e))?
123
        .into_iter()
124
        .filter(|p| p.public || r.member)
125
        .collect();
a024e800 Andy McCall ↶ 126
    let orgs = own_orgs(&app, &viewer, &owner).await;
7b26e611 Andy McCall ↶ 127
    let closed = query.state.as_deref() == Some("closed");
128
    Ok(Page(ProjectsPage::build(shell, owner, projects, r.edit, None, orgs, query.q, closed)))
e1ec9fbf Andy McCall ↶ 129
}
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
142
pub async fn create(
143
    State(app): State<Shared>,
144
    viewer: Viewer,
145
    Path(owner): Path<String>,
146
    Form(f): Form<NewProjectForm>,
147
) -> Result<Response, Error> {
148
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
149
    csrf(&viewer, &f.csrf_token, &shell)?;
150
    let r = rights(&app, &shell, &viewer, &owner).await?;
151
    if !r.edit {
152
        return Err(Error::not_found(shell));
153
    }
154
    let title = f.title.trim();
155
    if title.is_empty() || title.len() > 100 {
156
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
a024e800 Andy McCall ↶ 157
        let orgs = own_orgs(&app, &viewer, &owner).await;
7b26e611 Andy McCall ↶ 158
        return Ok(Page(ProjectsPage::build(
e1ec9fbf Andy McCall ↶ 159
            shell,
160
            owner,
161
            projects,
7b26e611 Andy McCall ↶ 162
            true,
163
            Some("A project needs a title of up to 100 characters.".into()),
a024e800 Andy McCall ↶ 164
            orgs,
7b26e611 Andy McCall ↶ 165
            None,
166
            false,
167
        ))
e1ec9fbf Andy McCall ↶ 168
        .into_response());
169
    }
170
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
171
        .await
172
        .map_err(|e| Error::from_db(shell.clone(), e))?;
173
    db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
174
    Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response())
175
}
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
}
187
188
pub struct ColumnView {
189
    pub column: pdb::Column,
190
    pub cards: Vec<CardView>,
191
}
192
193
#[derive(Template)]
194
#[template(path = "project.html")]
195
pub struct BoardPage {
196
    shell: Shell,
197
    project: pdb::Project,
198
    columns: Vec<ColumnView>,
199
    /// The owner's repositories the viewer can read, for adding cards.
200
    repos: Vec<String>,
201
    /// Changes cards and columns (an open project).
202
    can_edit: bool,
203
    /// Changes the settings (to reopen a closed project too).
204
    can_manage: bool,
205
    notice: Option<String>,
206
    error: Option<String>,
207
}
208
209
async fn board(
210
    app: &Shared,
211
    shell: Shell,
212
    viewer: &Viewer,
213
    owner: &str,
214
    number: i64,
215
    notice: Option<String>,
216
    error: Option<String>,
217
) -> Result<Page<BoardPage>, Error> {
218
    let e = |e| Error::from_db(shell.clone(), e);
219
    let r = rights(app, &shell, viewer, owner).await?;
220
    let project = pdb::get(&app.db, owner, number)
221
        .await
222
        .map_err(e)?
223
        .filter(|p| p.public || r.member)
224
        .ok_or_else(|| Error::not_found(shell.clone()))?;
ef20fdc1 Andy McCall ↶ 225
    let (columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?;
226
    let can_edit = r.edit && !project.closed;
227
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error }))
228
}
229
230
/// A project's columns with the cards `account` may see, and the owner's
231
/// repositories it can read (for adding cards).
232
pub async fn board_columns(
233
    app: &Shared,
234
    account: Option<&db::accounts::Account>,
235
    owner: &str,
236
    project: &pdb::Project,
237
) -> sqlx::Result<(Vec<ColumnView>, Vec<String>)> {
e1ec9fbf Andy McCall ↶ 238
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
ef20fdc1 Andy McCall ↶ 239
    for row in db::repos::list_by_owner(&app.db, owner).await? {
240
        let role = roles::role_for(&app.db, account, &row).await?;
e1ec9fbf Andy McCall ↶ 241
        if roles::allows(role, Access::Read) {
242
            readable.insert(row.id, row);
243
        }
244
    }
ef20fdc1 Andy McCall ↶ 245
    let cards = pdb::cards(&app.db, project.id).await?;
e1ec9fbf Andy McCall ↶ 246
    let mut columns = Vec::new();
ef20fdc1 Andy McCall ↶ 247
    for column in pdb::columns(&app.db, project.id).await? {
e1ec9fbf Andy McCall ↶ 248
        let mut views = Vec::new();
249
        for card in cards.iter().filter(|c| c.column_id == column.id) {
250
            match (card.kind.as_str(), card.repo_id, card.number) {
251
                ("note", _, _) => views.push(CardView {
252
                    card: card.clone(),
253
                    reference: String::new(),
254
                    url: String::new(),
255
                    title: card.note.clone(),
256
                    state: String::new(),
257
                }),
258
                (kind, Some(rid), Some(n)) => {
259
                    // Cards of repositories the viewer cannot read are not shown at all.
260
                    let Some(row) = readable.get(&rid) else { continue };
261
                    let (title, state) = if kind == "issue" {
ef20fdc1 Andy McCall ↶ 262
                        match db::issues::get(&app.db, rid, n).await? {
e1ec9fbf Andy McCall ↶ 263
                            Some(i) => (i.title, i.state),
264
                            None => continue,
265
                        }
266
                    } else {
ef20fdc1 Andy McCall ↶ 267
                        match db::proposals::get(&app.db, rid, n).await? {
e1ec9fbf Andy McCall ↶ 268
                            Some(p) => (p.title, p.state),
269
                            None => continue,
270
                        }
271
                    };
272
                    let slug = format!("{}/{}", row.owner, row.name);
273
                    views.push(CardView {
274
                        card: card.clone(),
275
                        reference: format!("{slug}#{n}"),
276
                        url: format!("/{slug}/{kind}/{n}"),
277
                        title,
278
                        state,
279
                    });
280
                }
281
                _ => {}
282
            }
283
        }
284
        columns.push(ColumnView { column, cards: views });
285
    }
286
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
287
    repos.sort();
ef20fdc1 Andy McCall ↶ 288
    Ok((columns, repos))
e1ec9fbf Andy McCall ↶ 289
}
290
291
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Page<BoardPage>, Error> {
292
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
293
    board(&app, shell, &viewer, &owner, number, None, None).await
294
}
295
296
#[derive(Deserialize)]
297
pub struct BoardForm {
298
    #[serde(rename = "_csrf")]
299
    csrf_token: String,
300
    action: String,
301
    #[serde(default)]
302
    card: Option<i64>,
303
    #[serde(default)]
304
    column: Option<i64>,
305
    #[serde(default)]
306
    name: String,
307
    /// `repo#12`, or `#12` with `repo`.
308
    #[serde(default)]
309
    reference: String,
310
    #[serde(default)]
311
    repo: String,
312
    #[serde(default)]
313
    note: String,
314
    #[serde(default)]
315
    title: String,
316
    #[serde(default)]
317
    description: String,
318
    #[serde(default)]
319
    private: Option<String>,
320
    #[serde(default)]
321
    closed: Option<String>,
322
}
323
324
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
efb51b39 Andy McCall ↶ 325
/// `repo#12`, `#12` with `repo`, or `owner/repo#12` when `owner` is the
326
/// board's (the form the board itself shows, oxygenhub#118). `Err` says
327
/// what is wrong.
328
fn parse_reference(reference: &str, repo: &str, owner: &str) -> Result<(String, i64), String> {
329
    let bad = || "Name an issue or proposal as repo#12, or write a note.".to_string();
e1ec9fbf Andy McCall ↶ 330
    let r = reference.trim();
efb51b39 Andy McCall ↶ 331
    let (name, n) = r.split_once('#').ok_or_else(bad)?;
e1ec9fbf Andy McCall ↶ 332
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
efb51b39 Andy McCall ↶ 333
    let name = match name.split_once('/') {
334
        Some((o, rest)) if o.eq_ignore_ascii_case(owner) => rest,
335
        Some((o, _)) => return Err(format!("This board holds only {owner}'s issues and proposals, not {o}'s.")),
336
        None => name,
337
    };
338
    let n: i64 = n.trim().parse().map_err(|_| bad())?;
339
    if name.is_empty() || n <= 0 || name.contains('/') {
340
        return Err(bad());
341
    }
342
    Ok((name.to_string(), n))
ef20fdc1 Andy McCall ↶ 343
}
344
345
/// Add a note, or the issue or proposal `reference` names (`repo#12`, or
346
/// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller
347
/// should be told: an unknown repository or number, or a card already there.
348
#[allow(clippy::too_many_arguments)]
349
pub async fn add_card(
350
    app: &Shared,
351
    account: Option<&db::accounts::Account>,
352
    owner: &str,
353
    project_id: i64,
354
    column_id: i64,
355
    reference: &str,
356
    repo: &str,
357
    note: &str,
358
    actor: &str,
359
) -> sqlx::Result<Result<pdb::Card, String>> {
360
    if !note.trim().is_empty() {
361
        let note: String = note.trim().chars().take(500).collect();
362
        pdb::add_card(
363
            &app.db,
364
            project_id,
365
            pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: actor },
366
        )
367
        .await?;
368
        return Ok(Ok(newest_card(app, project_id).await?));
369
    }
efb51b39 Andy McCall ↶ 370
    let (repo, n) = match parse_reference(reference, repo, owner) {
371
        Ok(r) => r,
372
        Err(why) => return Ok(Err(why)),
ef20fdc1 Andy McCall ↶ 373
    };
374
    let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) };
375
    let role = roles::role_for(&app.db, account, &row).await?;
376
    let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?;
377
    let kind = if issues.contains(&n) {
378
        "issue"
379
    } else if proposals.contains(&n) {
380
        "proposal"
381
    } else {
382
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
383
    };
384
    if !roles::allows(role, Access::Read) {
385
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
386
    }
387
    if !pdb::add_card(
388
        &app.db,
389
        project_id,
390
        pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor },
391
    )
392
    .await?
393
    {
394
        return Ok(Err(format!("{repo}#{n} is already on this project.")));
395
    }
396
    Ok(Ok(newest_card(app, project_id).await?))
397
}
398
399
async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result<pdb::Card> {
400
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = ? ORDER BY id DESC LIMIT 1").bind(project_id).fetch_one(&app.db).await
e1ec9fbf Andy McCall ↶ 401
}
402
403
pub async fn act(
404
    State(app): State<Shared>,
405
    viewer: Viewer,
406
    Path((owner, number)): Path<(String, i64)>,
407
    Form(f): Form<BoardForm>,
408
) -> Result<Response, Error> {
409
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
410
    csrf(&viewer, &f.csrf_token, &shell)?;
411
    let r = rights(&app, &shell, &viewer, &owner).await?;
412
    let e = |e| Error::from_db(shell.clone(), e);
413
    let project = pdb::get(&app.db, &owner, number)
414
        .await
415
        .map_err(e)?
416
        .filter(|p| p.public || r.member)
417
        .ok_or_else(|| Error::not_found(shell.clone()))?;
418
    if !r.edit {
419
        return Err(Error::not_found(shell));
420
    }
421
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete") {
422
        return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into()))
423
            .await?
424
            .into_response());
425
    }
426
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
427
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
428
    let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id));
429
    let mut notice = None;
430
    let mut error = None;
431
    match f.action.as_str() {
432
        "add" => {
433
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
ef20fdc1 Andy McCall ↶ 434
            if let Err(msg) =
435
                add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)?
436
            {
437
                error = Some(msg);
e1ec9fbf Andy McCall ↶ 438
            }
439
        }
440
        "move" => {
441
            if let (Some(card), Some(col)) = (f.card, f.column) {
442
                pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?;
443
            }
444
        }
445
        "up" | "down" => {
446
            if let Some(card) = f.card {
447
                pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?;
448
            }
449
        }
450
        "remove" => {
451
            if let Some(card) = f.card {
452
                pdb::remove_card(&app.db, project.id, card).await.map_err(e)?;
453
            }
454
        }
455
        "add_column" => {
456
            let name = f.name.trim();
457
            if name.is_empty() || name.len() > 40 {
458
                error = Some("A column has a name of up to 40 characters.".into());
459
            } else {
460
                pdb::add_column(&app.db, project.id, name).await.map_err(e)?;
461
            }
462
        }
463
        "rename_column" => {
464
            if let (Some(col), name) = (f.column, f.name.trim()) {
465
                if !name.is_empty() && name.len() <= 40 {
466
                    pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?;
467
                }
468
            }
469
        }
470
        "left" | "right" => {
471
            if let Some(col) = f.column {
472
                pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?;
473
            }
474
        }
475
        "remove_column" => {
476
            if let Some(col) = f.column {
477
                if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? {
478
                    error = Some("A project keeps at least one column.".into());
479
                }
480
            }
481
        }
482
        "settings" => {
483
            let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() };
484
            pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?;
485
            notice = Some("Saved.".into());
486
        }
487
        "delete" => {
488
            pdb::delete(&app.db, project.id).await.map_err(e)?;
489
            db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok();
490
            return Ok(Redirect::to(&format!("/projects/{owner}")).into_response());
491
        }
492
        _ => return Err(Error::bad(shell, "unknown action")),
493
    }
494
    if notice.is_none() && error.is_none() {
495
        // Back to the board, so a reload does not repeat the action.
496
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
497
    }
498
    Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response())
499
}
500
501
/// The projects an issue or proposal is on that the viewer may see, with
502
/// their column.
503
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> {
504
    let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] };
505
    let mut out = Vec::new();
506
    for (p, col) in all {
507
        let visible = p.public
508
            || viewer.is_admin()
509
            || match viewer.account() {
510
                Some(v) if v.username == p.owner => true,
511
                Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() {
512
                    Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(),
513
                    None => false,
514
                },
515
                None => false,
516
            };
517
        if visible {
518
            out.push((p, col));
519
        }
520
    }
521
    out
522
}
523
524
#[cfg(test)]
525
mod tests {
526
    use super::parse_reference;
527
528
    #[test]
529
    fn references() {
efb51b39 Andy McCall ↶ 530
        let p = |r: &str, repo: &str| parse_reference(r, repo, "andy").ok();
531
        assert_eq!(p("oxygen#12", ""), Some(("oxygen".into(), 12)));
532
        assert_eq!(p("#7", "hub"), Some(("hub".into(), 7)));
533
        assert_eq!(p(" hub # 3 ", ""), Some(("hub".into(), 3)));
534
        assert_eq!(p("#7", ""), None, "a repository is needed");
535
        assert_eq!(p("andy/hub#4", ""), Some(("hub".into(), 4)), "the board's own owner, as it shows cards");
536
        assert!(parse_reference("other/repo#1", "", "andy").unwrap_err().contains("only andy's"), "another owner is refused, saying why");
537
        assert_eq!(p("hub#0", ""), None);
538
        assert_eq!(p("hub", ""), None);
e1ec9fbf Andy McCall ↶ 539
    }
540
}
a1b98695 Andy McCall ↶ 541
542
#[derive(Deserialize)]
543
pub struct PlaceForm {
544
    #[serde(rename = "_csrf")]
545
    csrf_token: String,
546
    /// The project's number among its owner's.
547
    project: i64,
548
    /// `add` or `remove`.
549
    action: String,
550
}
551
552
/// Put an issue or proposal on one of its repository owner's projects (in
553
/// the first column), or take it off, from its own page.
554
async fn place(
555
    app: Shared,
556
    viewer: Viewer,
557
    owner: String,
558
    name: String,
559
    kind: &'static str,
560
    number: i64,
561
    f: PlaceForm,
562
) -> Result<Response, Error> {
563
    let page = format!("/{owner}/{name}/{kind}/{number}");
564
    let shell = Shell::new(&app, &page, &viewer);
565
    csrf(&viewer, &f.csrf_token, &shell)?;
566
    let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?;
567
    let r = rights(&app, &shell, &viewer, &row.owner).await?;
568
    if !r.edit {
569
        return Err(Error::not_found(shell));
570
    }
571
    let e = |e| Error::from_db(shell.clone(), e);
572
    let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
573
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
574
    let mut error = None;
575
    match f.action.as_str() {
576
        "add" => {
577
            let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
578
            let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) };
579
            if let Err(msg) =
580
                add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor)
581
                    .await
582
                    .map_err(e)?
583
            {
584
                error = Some(msg);
585
            }
586
        }
587
        "remove" => {
588
            for c in pdb::cards(&app.db, project.id).await.map_err(e)? {
589
                if c.repo_id == Some(row.id) && c.number == Some(number) {
590
                    pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?;
591
                }
592
            }
593
        }
594
        _ => return Err(Error::bad(shell, "add or remove")),
595
    }
596
    let to = match error {
597
        Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)),
598
        None => page,
599
    };
600
    Ok(Redirect::to(&to).into_response())
601
}
602
603
/// `POST /{owner}/{name}/proposal/{number}/project`.
604
pub async fn place_proposal(
605
    State(app): State<Shared>,
606
    viewer: Viewer,
607
    Path((owner, name, number)): Path<(String, String, i64)>,
608
    Form(f): Form<PlaceForm>,
609
) -> Result<Response, Error> {
610
    place(app, viewer, owner, name, "proposal", number, f).await
611
}
612
613
/// `POST /{owner}/{name}/issue/{number}/project`.
614
pub async fn place_issue(
615
    State(app): State<Shared>,
616
    viewer: Viewer,
617
    Path((owner, name, number)): Path<(String, String, i64)>,
618
    Form(f): Form<PlaceForm>,
619
) -> Result<Response, Error> {
620
    place(app, viewer, owner, name, "issue", number, f).await
621
}
622
623
/// The owner's open projects `viewer` may add an item to that it is not on
624
/// yet; `None` when the viewer may not change the owner's projects at all.
625
pub async fn project_choices(
626
    app: &Shared,
627
    viewer: &Viewer,
628
    repo: &db::repos::RepoRow,
629
    placed: &[(pdb::Project, String)],
630
) -> Option<Vec<pdb::Project>> {
631
    let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None };
632
    if !r.edit {
633
        return None;
634
    }
635
    Some(
636
        pdb::of_owner(&app.db, &repo.owner)
637
            .await
638
            .unwrap_or_default()
639
            .into_iter()
640
            .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id))
641
            .collect(),
642
    )
643
}