OXYGENHUB

Projects: boards of issues, proposals and notes across an owner's repositories, with columns, cards moved without JavaScript, private projects, and the projects shown on issues and proposals e1ec9fbf538e

closes #5
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 14:56 UTC
Signature
✔ signed by Andy McCall
Hash
e1ec9fbf538eea2cedb1c7ae7b4cb6f4f7a83d1d5b9e310a061892892295f330
Depends on
101740454617 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 43e8ca33bc5d 756fe44a0f4d 93be9ce4653e b64e2537d702 d23464c0ac60 de505df5cf8d eb0c44a9ad37
Depended on by
40a43921ef42 f2d2776b2a14 0bc95135954d 306aec1839aa 046e3352cdfd 1000b40a2629 946fe7a09faa 439ecd8708a1 6a306cd03fc1 657e7d5c9da9 456f4197ade3 16ee5d19d220 a7aa76ac52d7 d1bf2d698777 5d90e0e045e2 61ecbd299f22 7f283f8e8b97 b1e92a6233ee 7b26e6118bc0 a024e800af4e efb51b39db1c 8e96499f2705 cb71e18eb6f9 cc1cd197df1d a1b9869565aa 38833ed78707 941abdd6bb4c ef20fdc14e8b c79a1111dd56 decb6304ba97
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
23 atoms, 5 tree operations, 17 files

Browse the tree at this change

added crates/hub/migrations/0015_projects.sql +38 −0
1 +
-- Projects: boards of issues, proposals and notes across one owner's
2 +
-- repositories (hub spec §8.2).
3 +
4 +
CREATE TABLE projects (
5 +
    id          INTEGER PRIMARY KEY,
6 +
    owner       TEXT NOT NULL,
7 +
    number      INTEGER NOT NULL,
8 +
    title       TEXT NOT NULL,
9 +
    description TEXT NOT NULL DEFAULT '',
10 +
    public      INTEGER NOT NULL DEFAULT 1,
11 +
    closed      INTEGER NOT NULL DEFAULT 0,
12 +
    created_ms  INTEGER NOT NULL,
13 +
    updated_ms  INTEGER NOT NULL,
14 +
    UNIQUE (owner, number)
15 +
);
16 +
17 +
CREATE TABLE project_columns (
18 +
    id         INTEGER PRIMARY KEY,
19 +
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
20 +
    name       TEXT NOT NULL,
21 +
    position   INTEGER NOT NULL
22 +
);
23 +
CREATE INDEX project_columns_by_project ON project_columns (project_id, position);
24 +
25 +
CREATE TABLE project_cards (
26 +
    id         INTEGER PRIMARY KEY,
27 +
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
28 +
    column_id  INTEGER NOT NULL REFERENCES project_columns(id) ON DELETE CASCADE,
29 +
    position   INTEGER NOT NULL,
30 +
    kind       TEXT NOT NULL CHECK (kind IN ('issue', 'proposal', 'note')),
31 +
    repo_id    INTEGER REFERENCES repositories(id) ON DELETE CASCADE,
32 +
    number     INTEGER,
33 +
    note       TEXT NOT NULL DEFAULT '',
34 +
    added_by   TEXT NOT NULL DEFAULT '',
35 +
    created_ms INTEGER NOT NULL
36 +
);
37 +
CREATE INDEX project_cards_by_column ON project_cards (column_id, position);
38 +
CREATE UNIQUE INDEX project_cards_once ON project_cards (project_id, repo_id, number) WHERE kind <> 'note';
added crates/hub/src/db/projects.rs +281 −0
1 +
//! Projects, their columns and cards (hub spec §8.2).
2 +
3 +
use super::{now_ms, Pool};
4 +
use sqlx::FromRow;
5 +
6 +
#[derive(Clone, Debug, FromRow)]
7 +
pub struct Project {
8 +
    pub id: i64,
9 +
    pub owner: String,
10 +
    pub number: i64,
11 +
    pub title: String,
12 +
    pub description: String,
13 +
    pub public: bool,
14 +
    pub closed: bool,
15 +
    pub created_ms: i64,
16 +
    pub updated_ms: i64,
17 +
}
18 +
19 +
#[derive(Clone, Debug, FromRow)]
20 +
pub struct Column {
21 +
    pub id: i64,
22 +
    pub project_id: i64,
23 +
    pub name: String,
24 +
    pub position: i64,
25 +
}
26 +
27 +
#[derive(Clone, Debug, FromRow)]
28 +
pub struct Card {
29 +
    pub id: i64,
30 +
    pub project_id: i64,
31 +
    pub column_id: i64,
32 +
    pub position: i64,
33 +
    pub kind: String,
34 +
    pub repo_id: Option<i64>,
35 +
    pub number: Option<i64>,
36 +
    pub note: String,
37 +
    pub added_by: String,
38 +
    pub created_ms: i64,
39 +
}
40 +
41 +
pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"];
42 +
43 +
pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> {
44 +
    let mut tx = pool.begin().await?;
45 +
    let number: i64 =
46 +
        sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = ?").bind(owner).fetch_one(&mut *tx).await?;
47 +
    let now = now_ms();
48 +
    let p: Project = sqlx::query_as(
49 +
        "INSERT INTO projects (owner, number, title, description, public, created_ms, updated_ms) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *",
50 +
    )
51 +
    .bind(owner)
52 +
    .bind(number)
53 +
    .bind(title)
54 +
    .bind(description)
55 +
    .bind(public)
56 +
    .bind(now)
57 +
    .bind(now)
58 +
    .fetch_one(&mut *tx)
59 +
    .await?;
60 +
    for (i, name) in DEFAULT_COLUMNS.iter().enumerate() {
61 +
        sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES (?, ?, ?)")
62 +
            .bind(p.id)
63 +
            .bind(name)
64 +
            .bind(i as i64)
65 +
            .execute(&mut *tx)
66 +
            .await?;
67 +
    }
68 +
    tx.commit().await?;
69 +
    Ok(p)
70 +
}
71 +
72 +
pub async fn of_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Project>> {
73 +
    sqlx::query_as("SELECT * FROM projects WHERE owner = ? ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await
74 +
}
75 +
76 +
pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> {
77 +
    sqlx::query_as("SELECT * FROM projects WHERE owner = ? AND number = ?").bind(owner).bind(number).fetch_optional(pool).await
78 +
}
79 +
80 +
pub async fn set(pool: &Pool, id: i64, title: &str, description: &str, public: bool, closed: bool) -> sqlx::Result<()> {
81 +
    sqlx::query("UPDATE projects SET title = ?, description = ?, public = ?, closed = ?, updated_ms = ? WHERE id = ?")
82 +
        .bind(title)
83 +
        .bind(description)
84 +
        .bind(public)
85 +
        .bind(closed)
86 +
        .bind(now_ms())
87 +
        .bind(id)
88 +
        .execute(pool)
89 +
        .await?;
90 +
    Ok(())
91 +
}
92 +
93 +
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
94 +
    sqlx::query("DELETE FROM projects WHERE id = ?").bind(id).execute(pool).await?;
95 +
    Ok(())
96 +
}
97 +
98 +
async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
99 +
    sqlx::query("UPDATE projects SET updated_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?;
100 +
    Ok(())
101 +
}
102 +
103 +
pub async fn columns(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Column>> {
104 +
    sqlx::query_as("SELECT * FROM project_columns WHERE project_id = ? ORDER BY position, id").bind(project_id).fetch_all(pool).await
105 +
}
106 +
107 +
pub async fn cards(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<Card>> {
108 +
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = ? ORDER BY position, id").bind(project_id).fetch_all(pool).await
109 +
}
110 +
111 +
pub async fn add_column(pool: &Pool, project_id: i64, name: &str) -> sqlx::Result<()> {
112 +
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_columns WHERE project_id = ?")
113 +
        .bind(project_id)
114 +
        .fetch_one(pool)
115 +
        .await?;
116 +
    sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES (?, ?, ?)")
117 +
        .bind(project_id)
118 +
        .bind(name)
119 +
        .bind(pos)
120 +
        .execute(pool)
121 +
        .await?;
122 +
    touch(pool, project_id).await
123 +
}
124 +
125 +
pub async fn rename_column(pool: &Pool, project_id: i64, column_id: i64, name: &str) -> sqlx::Result<()> {
126 +
    sqlx::query("UPDATE project_columns SET name = ? WHERE id = ? AND project_id = ?")
127 +
        .bind(name)
128 +
        .bind(column_id)
129 +
        .bind(project_id)
130 +
        .execute(pool)
131 +
        .await?;
132 +
    touch(pool, project_id).await
133 +
}
134 +
135 +
/// Swap a column with its neighbour (`-1` left, `1` right).
136 +
pub async fn shift_column(pool: &Pool, project_id: i64, column_id: i64, by: i64) -> sqlx::Result<()> {
137 +
    let cols = columns(pool, project_id).await?;
138 +
    let Some(i) = cols.iter().position(|c| c.id == column_id) else { return Ok(()) };
139 +
    let j = i as i64 + by;
140 +
    if j < 0 || j as usize >= cols.len() {
141 +
        return Ok(());
142 +
    }
143 +
    let mut ids: Vec<i64> = cols.iter().map(|c| c.id).collect();
144 +
    ids.swap(i, j as usize);
145 +
    for (pos, id) in ids.iter().enumerate() {
146 +
        sqlx::query("UPDATE project_columns SET position = ? WHERE id = ?").bind(pos as i64).bind(id).execute(pool).await?;
147 +
    }
148 +
    touch(pool, project_id).await
149 +
}
150 +
151 +
/// Remove a column; its cards go to the first remaining column. The last column stays.
152 +
pub async fn remove_column(pool: &Pool, project_id: i64, column_id: i64) -> sqlx::Result<bool> {
153 +
    let cols = columns(pool, project_id).await?;
154 +
    let Some(to) = cols.iter().find(|c| c.id != column_id) else { return Ok(false) };
155 +
    if !cols.iter().any(|c| c.id == column_id) {
156 +
        return Ok(false);
157 +
    }
158 +
    let mut tx = pool.begin().await?;
159 +
    let base: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?")
160 +
        .bind(to.id)
161 +
        .fetch_one(&mut *tx)
162 +
        .await?;
163 +
    sqlx::query("UPDATE project_cards SET column_id = ?, position = position + ? WHERE column_id = ?")
164 +
        .bind(to.id)
165 +
        .bind(base)
166 +
        .bind(column_id)
167 +
        .execute(&mut *tx)
168 +
        .await?;
169 +
    sqlx::query("DELETE FROM project_columns WHERE id = ?").bind(column_id).execute(&mut *tx).await?;
170 +
    tx.commit().await?;
171 +
    touch(pool, project_id).await?;
172 +
    Ok(true)
173 +
}
174 +
175 +
pub struct NewCard<'a> {
176 +
    pub column_id: i64,
177 +
    pub kind: &'a str,
178 +
    pub repo_id: Option<i64>,
179 +
    pub number: Option<i64>,
180 +
    pub note: &'a str,
181 +
    pub added_by: &'a str,
182 +
}
183 +
184 +
/// Add a card at the bottom of a column; `Ok(false)` when that issue or
185 +
/// proposal is already on the project.
186 +
pub async fn add_card(pool: &Pool, project_id: i64, c: NewCard<'_>) -> sqlx::Result<bool> {
187 +
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?")
188 +
        .bind(c.column_id)
189 +
        .fetch_one(pool)
190 +
        .await?;
191 +
    let r = sqlx::query(
192 +
        "INSERT OR IGNORE INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
193 +
    )
194 +
    .bind(project_id)
195 +
    .bind(c.column_id)
196 +
    .bind(pos)
197 +
    .bind(c.kind)
198 +
    .bind(c.repo_id)
199 +
    .bind(c.number)
200 +
    .bind(c.note)
201 +
    .bind(c.added_by)
202 +
    .bind(now_ms())
203 +
    .execute(pool)
204 +
    .await?;
205 +
    touch(pool, project_id).await?;
206 +
    Ok(r.rows_affected() == 1)
207 +
}
208 +
209 +
/// Move a card to the bottom of another column of the same project.
210 +
pub async fn move_card(pool: &Pool, project_id: i64, card_id: i64, column_id: i64) -> sqlx::Result<()> {
211 +
    let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_columns WHERE id = ? AND project_id = ?")
212 +
        .bind(column_id)
213 +
        .bind(project_id)
214 +
        .fetch_one(pool)
215 +
        .await?;
216 +
    if ok == 0 {
217 +
        return Ok(());
218 +
    }
219 +
    let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = ?")
220 +
        .bind(column_id)
221 +
        .fetch_one(pool)
222 +
        .await?;
223 +
    sqlx::query("UPDATE project_cards SET column_id = ?, position = ? WHERE id = ? AND project_id = ?")
224 +
        .bind(column_id)
225 +
        .bind(pos)
226 +
        .bind(card_id)
227 +
        .bind(project_id)
228 +
        .execute(pool)
229 +
        .await?;
230 +
    touch(pool, project_id).await
231 +
}
232 +
233 +
/// Swap a card with its neighbour in its column (`-1` up, `1` down).
234 +
pub async fn shift_card(pool: &Pool, project_id: i64, card_id: i64, by: i64) -> sqlx::Result<()> {
235 +
    let Some(card): Option<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE id = ? AND project_id = ?")
236 +
        .bind(card_id)
237 +
        .bind(project_id)
238 +
        .fetch_optional(pool)
239 +
        .await?
240 +
    else {
241 +
        return Ok(());
242 +
    };
243 +
    let col: Vec<Card> = sqlx::query_as("SELECT * FROM project_cards WHERE column_id = ? ORDER BY position, id")
244 +
        .bind(card.column_id)
245 +
        .fetch_all(pool)
246 +
        .await?;
247 +
    let Some(i) = col.iter().position(|c| c.id == card_id) else { return Ok(()) };
248 +
    let j = i as i64 + by;
249 +
    if j < 0 || j as usize >= col.len() {
250 +
        return Ok(());
251 +
    }
252 +
    let mut ids: Vec<i64> = col.iter().map(|c| c.id).collect();
253 +
    ids.swap(i, j as usize);
254 +
    for (pos, id) in ids.iter().enumerate() {
255 +
        sqlx::query("UPDATE project_cards SET position = ? WHERE id = ?").bind(pos as i64).bind(id).execute(pool).await?;
256 +
    }
257 +
    touch(pool, project_id).await
258 +
}
259 +
260 +
pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> {
261 +
    sqlx::query("DELETE FROM project_cards WHERE id = ? AND project_id = ?").bind(card_id).bind(project_id).execute(pool).await?;
262 +
    touch(pool, project_id).await
263 +
}
264 +
265 +
/// The projects (and column names) an issue or proposal is on.
266 +
pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> {
267 +
    let rows: Vec<(i64, String)> = sqlx::query_as(
268 +
        "SELECT c.project_id, col.name FROM project_cards c JOIN project_columns col ON col.id = c.column_id WHERE c.repo_id = ? AND c.number = ? AND c.kind <> 'note'",
269 +
    )
270 +
    .bind(repo_id)
271 +
    .bind(number)
272 +
    .fetch_all(pool)
273 +
    .await?;
274 +
    let mut out = Vec::new();
275 +
    for (pid, col) in rows {
276 +
        if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = ?").bind(pid).fetch_optional(pool).await? {
277 +
            out.push((p, col));
278 +
        }
279 +
    }
280 +
    Ok(out)
281 +
}
added crates/hub/src/web/projects.rs +427 −0
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;
10 +
use axum::extract::{Form, Path, State};
11 +
use axum::response::{IntoResponse, Redirect, Response};
12 +
use serde::Deserialize;
13 +
14 +
/// What the viewer may do with an owner's projects.
15 +
struct Rights {
16 +
    /// Sees private projects.
17 +
    member: bool,
18 +
    /// Creates and changes projects.
19 +
    edit: bool,
20 +
}
21 +
22 +
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
23 +
    let e = |e| Error::from_db(shell.clone(), e);
24 +
    let admin = viewer.is_admin();
25 +
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await.map_err(e)? {
26 +
        let me = viewer.account().is_some_and(|v| v.id == a.id);
27 +
        return Ok(Rights { member: me || admin, edit: me || admin });
28 +
    }
29 +
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await.map_err(e)? {
30 +
        let member = match viewer.account() {
31 +
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await.map_err(e)?.is_some(),
32 +
            None => false,
33 +
        };
34 +
        return Ok(Rights { member: member || admin, edit: member || admin });
35 +
    }
36 +
    Err(Error::not_found(shell.clone()))
37 +
}
38 +
39 +
#[derive(Template)]
40 +
#[template(path = "projects.html")]
41 +
pub struct ProjectsPage {
42 +
    shell: Shell,
43 +
    owner: String,
44 +
    projects: Vec<pdb::Project>,
45 +
    can_edit: bool,
46 +
    error: Option<String>,
47 +
}
48 +
49 +
pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> {
50 +
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
51 +
    let r = rights(&app, &shell, &viewer, &owner).await?;
52 +
    let projects = pdb::of_owner(&app.db, &owner)
53 +
        .await
54 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
55 +
        .into_iter()
56 +
        .filter(|p| p.public || r.member)
57 +
        .collect();
58 +
    Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None }))
59 +
}
60 +
61 +
#[derive(Deserialize)]
62 +
pub struct NewProjectForm {
63 +
    #[serde(rename = "_csrf")]
64 +
    csrf_token: String,
65 +
    title: String,
66 +
    #[serde(default)]
67 +
    description: String,
68 +
    #[serde(default)]
69 +
    private: Option<String>,
70 +
}
71 +
72 +
pub async fn create(
73 +
    State(app): State<Shared>,
74 +
    viewer: Viewer,
75 +
    Path(owner): Path<String>,
76 +
    Form(f): Form<NewProjectForm>,
77 +
) -> Result<Response, Error> {
78 +
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
79 +
    csrf(&viewer, &f.csrf_token, &shell)?;
80 +
    let r = rights(&app, &shell, &viewer, &owner).await?;
81 +
    if !r.edit {
82 +
        return Err(Error::not_found(shell));
83 +
    }
84 +
    let title = f.title.trim();
85 +
    if title.is_empty() || title.len() > 100 {
86 +
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
87 +
        return Ok(Page(ProjectsPage {
88 +
            shell,
89 +
            owner,
90 +
            projects,
91 +
            can_edit: true,
92 +
            error: Some("A project needs a title of up to 100 characters.".into()),
93 +
        })
94 +
        .into_response());
95 +
    }
96 +
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
97 +
        .await
98 +
        .map_err(|e| Error::from_db(shell.clone(), e))?;
99 +
    db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
100 +
    Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response())
101 +
}
102 +
103 +
/// A card as the board shows it.
104 +
pub struct CardView {
105 +
    pub card: pdb::Card,
106 +
    /// `owner/name#N`, for issue and proposal cards.
107 +
    pub reference: String,
108 +
    pub url: String,
109 +
    pub title: String,
110 +
    /// `open`, `closed`, `landed`, `draft`… for issues and proposals.
111 +
    pub state: String,
112 +
}
113 +
114 +
pub struct ColumnView {
115 +
    pub column: pdb::Column,
116 +
    pub cards: Vec<CardView>,
117 +
}
118 +
119 +
#[derive(Template)]
120 +
#[template(path = "project.html")]
121 +
pub struct BoardPage {
122 +
    shell: Shell,
123 +
    project: pdb::Project,
124 +
    columns: Vec<ColumnView>,
125 +
    /// The owner's repositories the viewer can read, for adding cards.
126 +
    repos: Vec<String>,
127 +
    /// Changes cards and columns (an open project).
128 +
    can_edit: bool,
129 +
    /// Changes the settings (to reopen a closed project too).
130 +
    can_manage: bool,
131 +
    notice: Option<String>,
132 +
    error: Option<String>,
133 +
}
134 +
135 +
async fn board(
136 +
    app: &Shared,
137 +
    shell: Shell,
138 +
    viewer: &Viewer,
139 +
    owner: &str,
140 +
    number: i64,
141 +
    notice: Option<String>,
142 +
    error: Option<String>,
143 +
) -> Result<Page<BoardPage>, Error> {
144 +
    let e = |e| Error::from_db(shell.clone(), e);
145 +
    let r = rights(app, &shell, viewer, owner).await?;
146 +
    let project = pdb::get(&app.db, owner, number)
147 +
        .await
148 +
        .map_err(e)?
149 +
        .filter(|p| p.public || r.member)
150 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
151 +
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
152 +
    for row in db::repos::list_by_owner(&app.db, owner).await.map_err(e)? {
153 +
        let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?;
154 +
        if roles::allows(role, Access::Read) {
155 +
            readable.insert(row.id, row);
156 +
        }
157 +
    }
158 +
    let cards = pdb::cards(&app.db, project.id).await.map_err(e)?;
159 +
    let mut columns = Vec::new();
160 +
    for column in pdb::columns(&app.db, project.id).await.map_err(e)? {
161 +
        let mut views = Vec::new();
162 +
        for card in cards.iter().filter(|c| c.column_id == column.id) {
163 +
            match (card.kind.as_str(), card.repo_id, card.number) {
164 +
                ("note", _, _) => views.push(CardView {
165 +
                    card: card.clone(),
166 +
                    reference: String::new(),
167 +
                    url: String::new(),
168 +
                    title: card.note.clone(),
169 +
                    state: String::new(),
170 +
                }),
171 +
                (kind, Some(rid), Some(n)) => {
172 +
                    // Cards of repositories the viewer cannot read are not shown at all.
173 +
                    let Some(row) = readable.get(&rid) else { continue };
174 +
                    let (title, state) = if kind == "issue" {
175 +
                        match db::issues::get(&app.db, rid, n).await.map_err(e)? {
176 +
                            Some(i) => (i.title, i.state),
177 +
                            None => continue,
178 +
                        }
179 +
                    } else {
180 +
                        match db::proposals::get(&app.db, rid, n).await.map_err(e)? {
181 +
                            Some(p) => (p.title, p.state),
182 +
                            None => continue,
183 +
                        }
184 +
                    };
185 +
                    let slug = format!("{}/{}", row.owner, row.name);
186 +
                    views.push(CardView {
187 +
                        card: card.clone(),
188 +
                        reference: format!("{slug}#{n}"),
189 +
                        url: format!("/{slug}/{kind}/{n}"),
190 +
                        title,
191 +
                        state,
192 +
                    });
193 +
                }
194 +
                _ => {}
195 +
            }
196 +
        }
197 +
        columns.push(ColumnView { column, cards: views });
198 +
    }
199 +
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
200 +
    repos.sort();
201 +
    let can_edit = r.edit && !project.closed;
202 +
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error }))
203 +
}
204 +
205 +
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Page<BoardPage>, Error> {
206 +
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
207 +
    board(&app, shell, &viewer, &owner, number, None, None).await
208 +
}
209 +
210 +
#[derive(Deserialize)]
211 +
pub struct BoardForm {
212 +
    #[serde(rename = "_csrf")]
213 +
    csrf_token: String,
214 +
    action: String,
215 +
    #[serde(default)]
216 +
    card: Option<i64>,
217 +
    #[serde(default)]
218 +
    column: Option<i64>,
219 +
    #[serde(default)]
220 +
    name: String,
221 +
    /// `repo#12`, or `#12` with `repo`.
222 +
    #[serde(default)]
223 +
    reference: String,
224 +
    #[serde(default)]
225 +
    repo: String,
226 +
    #[serde(default)]
227 +
    note: String,
228 +
    #[serde(default)]
229 +
    title: String,
230 +
    #[serde(default)]
231 +
    description: String,
232 +
    #[serde(default)]
233 +
    private: Option<String>,
234 +
    #[serde(default)]
235 +
    closed: Option<String>,
236 +
}
237 +
238 +
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
239 +
fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> {
240 +
    let r = reference.trim();
241 +
    let (name, n) = r.split_once('#')?;
242 +
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
243 +
    let n: i64 = n.trim().parse().ok()?;
244 +
    (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n))
245 +
}
246 +
247 +
pub async fn act(
248 +
    State(app): State<Shared>,
249 +
    viewer: Viewer,
250 +
    Path((owner, number)): Path<(String, i64)>,
251 +
    Form(f): Form<BoardForm>,
252 +
) -> Result<Response, Error> {
253 +
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
254 +
    csrf(&viewer, &f.csrf_token, &shell)?;
255 +
    let r = rights(&app, &shell, &viewer, &owner).await?;
256 +
    let e = |e| Error::from_db(shell.clone(), e);
257 +
    let project = pdb::get(&app.db, &owner, number)
258 +
        .await
259 +
        .map_err(e)?
260 +
        .filter(|p| p.public || r.member)
261 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
262 +
    if !r.edit {
263 +
        return Err(Error::not_found(shell));
264 +
    }
265 +
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete") {
266 +
        return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into()))
267 +
            .await?
268 +
            .into_response());
269 +
    }
270 +
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
271 +
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
272 +
    let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id));
273 +
    let mut notice = None;
274 +
    let mut error = None;
275 +
    match f.action.as_str() {
276 +
        "add" => {
277 +
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
278 +
            if !f.note.trim().is_empty() {
279 +
                let note: String = f.note.trim().chars().take(500).collect();
280 +
                pdb::add_card(
281 +
                    &app.db,
282 +
                    project.id,
283 +
                    pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: &actor },
284 +
                )
285 +
                .await
286 +
                .map_err(e)?;
287 +
            } else {
288 +
                match parse_reference(&f.reference, &f.repo) {
289 +
                    None => error = Some("Name an issue or proposal as repo#12, or write a note.".to_string()),
290 +
                    Some((repo, n)) => match db::repos::get(&app.db, &owner, &repo).await.map_err(e)? {
291 +
                        None => error = Some(format!("{owner} has no repository {repo}.")),
292 +
                        Some(row) => {
293 +
                            let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?;
294 +
                            let (issues, proposals) = db::issues::numbers(&app.db, row.id).await.map_err(e)?;
295 +
                            let kind = if issues.contains(&n) {
296 +
                                Some("issue")
297 +
                            } else if proposals.contains(&n) {
298 +
                                Some("proposal")
299 +
                            } else {
300 +
                                None
301 +
                            };
302 +
                            match (roles::allows(role, Access::Read), kind) {
303 +
                                (true, Some(kind)) => {
304 +
                                    let added = pdb::add_card(
305 +
                                        &app.db,
306 +
                                        project.id,
307 +
                                        pdb::NewCard {
308 +
                                            column_id,
309 +
                                            kind,
310 +
                                            repo_id: Some(row.id),
311 +
                                            number: Some(n),
312 +
                                            note: "",
313 +
                                            added_by: &actor,
314 +
                                        },
315 +
                                    )
316 +
                                    .await
317 +
                                    .map_err(e)?;
318 +
                                    if !added {
319 +
                                        error = Some(format!("{repo}#{n} is already on this project."));
320 +
                                    }
321 +
                                }
322 +
                                _ => error = Some(format!("{repo} has no issue or proposal #{n}.")),
323 +
                            }
324 +
                        }
325 +
                    },
326 +
                }
327 +
            }
328 +
        }
329 +
        "move" => {
330 +
            if let (Some(card), Some(col)) = (f.card, f.column) {
331 +
                pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?;
332 +
            }
333 +
        }
334 +
        "up" | "down" => {
335 +
            if let Some(card) = f.card {
336 +
                pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?;
337 +
            }
338 +
        }
339 +
        "remove" => {
340 +
            if let Some(card) = f.card {
341 +
                pdb::remove_card(&app.db, project.id, card).await.map_err(e)?;
342 +
            }
343 +
        }
344 +
        "add_column" => {
345 +
            let name = f.name.trim();
346 +
            if name.is_empty() || name.len() > 40 {
347 +
                error = Some("A column has a name of up to 40 characters.".into());
348 +
            } else {
349 +
                pdb::add_column(&app.db, project.id, name).await.map_err(e)?;
350 +
            }
351 +
        }
352 +
        "rename_column" => {
353 +
            if let (Some(col), name) = (f.column, f.name.trim()) {
354 +
                if !name.is_empty() && name.len() <= 40 {
355 +
                    pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?;
356 +
                }
357 +
            }
358 +
        }
359 +
        "left" | "right" => {
360 +
            if let Some(col) = f.column {
361 +
                pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?;
362 +
            }
363 +
        }
364 +
        "remove_column" => {
365 +
            if let Some(col) = f.column {
366 +
                if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? {
367 +
                    error = Some("A project keeps at least one column.".into());
368 +
                }
369 +
            }
370 +
        }
371 +
        "settings" => {
372 +
            let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() };
373 +
            pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?;
374 +
            notice = Some("Saved.".into());
375 +
        }
376 +
        "delete" => {
377 +
            pdb::delete(&app.db, project.id).await.map_err(e)?;
378 +
            db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok();
379 +
            return Ok(Redirect::to(&format!("/projects/{owner}")).into_response());
380 +
        }
381 +
        _ => return Err(Error::bad(shell, "unknown action")),
382 +
    }
383 +
    if notice.is_none() && error.is_none() {
384 +
        // Back to the board, so a reload does not repeat the action.
385 +
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
386 +
    }
387 +
    Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response())
388 +
}
389 +
390 +
/// The projects an issue or proposal is on that the viewer may see, with
391 +
/// their column.
392 +
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> {
393 +
    let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] };
394 +
    let mut out = Vec::new();
395 +
    for (p, col) in all {
396 +
        let visible = p.public
397 +
            || viewer.is_admin()
398 +
            || match viewer.account() {
399 +
                Some(v) if v.username == p.owner => true,
400 +
                Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() {
401 +
                    Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(),
402 +
                    None => false,
403 +
                },
404 +
                None => false,
405 +
            };
406 +
        if visible {
407 +
            out.push((p, col));
408 +
        }
409 +
    }
410 +
    out
411 +
}
412 +
413 +
#[cfg(test)]
414 +
mod tests {
415 +
    use super::parse_reference;
416 +
417 +
    #[test]
418 +
    fn references() {
419 +
        assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12)));
420 +
        assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7)));
421 +
        assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3)));
422 +
        assert_eq!(parse_reference("#7", ""), None, "a repository is needed");
423 +
        assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories");
424 +
        assert_eq!(parse_reference("hub#0", ""), None);
425 +
        assert_eq!(parse_reference("hub", ""), None);
426 +
    }
427 +
}
added templates/project.html +59 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{{ project.title }} · Projects · {{ project.owner }}{% endblock %}
3 +
{% block mainclass %} class="wide"{% endblock %}
4 +
{% block content %}
5 +
<div class="head"><h1>{{ project.title }} <small><a href="/projects/{{ project.owner }}">{{ project.owner }}'s projects</a> · #{{ project.number }}{% if !project.public %} · private{% endif %}{% if project.closed %} · closed{% endif %}</small></h1>
6 +
{% if !project.description.is_empty() %}<p class="mute">{{ project.description }}</p>{% endif %}</div>
7 +
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 +
<div class="board" role="list" aria-label="Columns">
10 +
{% for col in columns %}
11 +
<section class="board-col" role="listitem" aria-labelledby="col-{{ col.column.id }}">
12 +
  <header><h2 id="col-{{ col.column.id }}">{{ col.column.name }} <span class="count-chip">{{ col.cards.len() }}</span></h2>
13 +
  {% if can_edit %}<form class="tiny" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"><button name="action" value="left" title="Move the column left" aria-label="Move {{ col.column.name }} left">←</button><button name="action" value="right" title="Move the column right" aria-label="Move {{ col.column.name }} right">→</button></form>{% endif %}</header>
14 +
  <ul class="cards">
15 +
  {% for c in col.cards %}
16 +
    <li class="card-item{% if c.card.kind == "note" %} note{% endif %}">
17 +
      {% if c.card.kind == "note" %}<p>{{ c.title }}</p>
18 +
      {% else %}<a href="{{ c.url }}">{{ c.title }}</a>
19 +
      <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span></p>{% endif %}
20 +
      {% if can_edit %}
21 +
      <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}">
22 +
        <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label>
23 +
        <select id="mv-{{ c.card.id }}" name="column">{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.id == col.column.id %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select>
24 +
        <button name="action" value="move">move</button><button name="action" value="up" aria-label="Up">↑</button><button name="action" value="down" aria-label="Down">↓</button><button name="action" value="remove" aria-label="Remove from the project">✕</button>
25 +
      </form>{% endif %}
26 +
    </li>
27 +
  {% endfor %}
28 +
  </ul>
29 +
  {% if can_edit %}
30 +
  <details class="add-card"><summary class="small">add a card</summary>
31 +
    <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"><input type="hidden" name="action" value="add">
32 +
      <label for="ref-{{ col.column.id }}">Issue or proposal</label>
33 +
      <div class="inline-fields"><select name="repo" aria-label="Repository">{% for r in repos %}<option>{{ r }}</option>{% endfor %}</select><input type="text" id="ref-{{ col.column.id }}" name="reference" placeholder="#12 or repo#12"></div>
34 +
      <label for="note-{{ col.column.id }}">or a note</label><textarea id="note-{{ col.column.id }}" name="note" rows="2" maxlength="500"></textarea>
35 +
      <button type="submit" class="small">ADD</button>
36 +
    </form>
37 +
  </details>
38 +
  <details class="add-card"><summary class="small">column</summary>
39 +
    <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}">
40 +
      <input type="text" name="name" value="{{ col.column.name }}" aria-label="Column name" maxlength="40"><button name="action" value="rename_column" class="small">RENAME</button> <button name="action" value="remove_column" class="small danger-btn" onclick="return confirm('Remove this column? Its cards move to the first column.')">REMOVE</button>
41 +
    </form>
42 +
  </details>
43 +
  {% endif %}
44 +
</section>
45 +
{% endfor %}
46 +
{% if can_edit %}
47 +
<section class="board-col add-col"><form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="newcol">New column</label><input type="text" id="newcol" name="name" maxlength="40" required><button name="action" value="add_column" class="small">ADD COLUMN</button></form></section>
48 +
{% endif %}
49 +
</div>
50 +
{% if can_manage %}
51 +
<details class="panel"><summary>Settings</summary>
52 +
<form class="card" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
53 +
  <label for="ptitle">Title</label><input type="text" id="ptitle" name="title" value="{{ project.title }}" maxlength="100">
54 +
  <label for="pdesc">Description</label><input type="text" id="pdesc" name="description" value="{{ project.description }}">
55 +
  <div class="choice"><label><input type="checkbox" name="private" value="1"{% if !project.public %} checked{% endif %}> private</label> <label><input type="checkbox" name="closed" value="1"{% if project.closed %} checked{% endif %}> closed (read-only)</label></div>
56 +
  <button name="action" value="settings">SAVE</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete this project? Issues and proposals are not touched.')">DELETE PROJECT</button>
57 +
</form></details>
58 +
{% endif %}
59 +
{% endblock %}
added templates/projects.html +25 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Projects · {{ owner }}{% endblock %}
3 +
{% block content %}
4 +
<div class="head"><h1>Projects <small><a href="/{{ owner }}">{{ owner }}</a></small></h1></div>
5 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
6 +
{% if projects.is_empty() %}<p class="empty">No projects yet.{% if can_edit %} A project is a board of issues, proposals and notes from {{ owner }}'s repositories.{% endif %}</p>{% else %}
7 +
<ul class="rows">
8 +
{% for p in projects %}
9 +
  <li><div><a class="name" href="/projects/{{ owner }}/{{ p.number }}">{{ p.title }}</a>{% if !p.public %} <span class="tag">private</span>{% endif %}{% if p.closed %} <span class="tag dim">closed</span>{% endif %}
10 +
  <p class="small mute">#{{ p.number }}{% if !p.description.is_empty() %} · {{ p.description }}{% endif %} · updated <time title="{{ p.updated_ms|when_i }}">{{ p.updated_ms|ago_i }}</time></p></div></li>
11 +
{% endfor %}
12 +
</ul>
13 +
{% endif %}
14 +
{% if can_edit %}
15 +
<form class="card" method="post" action="/projects/{{ owner }}">
16 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
17 +
  <h2>New project</h2>
18 +
  <label for="title">Title</label><input type="text" id="title" name="title" maxlength="100" required>
19 +
  <label for="description">Description</label><input type="text" id="description" name="description">
20 +
  <div class="choice"><label><input type="checkbox" name="private" value="1"> private (only {% if owner == shell.user.clone().unwrap_or_default() %}you{% else %}members of {{ owner }}{% endif %})</label></div>
21 +
  <button type="submit">CREATE</button>
22 +
  <p class="small mute">It starts with the columns Backlog, Doing and Done.</p>
23 +
</form>
24 +
{% endif %}
25 +
{% endblock %}
modified crates/hub/src/db/mod.rs +1 −0
…
6 unchanged lines
7 7
pub mod hooks;
8 8
pub mod issues;
9 9
pub mod notify;
10 +
pub mod projects;
10 11
pub mod proposals;
11 12
pub mod releases;
12 13
pub mod repos;
…
25 unchanged lines
modified crates/hub/src/repos/names.rs +1 −0
…
29 unchanged lines
30 30
    "health",
31 31
    "avatar",
32 32
    "help",
33 +
    "projects",
33 34
    "assets",
34 35
    ".well-known",
35 36
];
…
62 unchanged lines
modified crates/hub/src/web/issues.rs +4 −0
…
301 unchanged lines
302 302
    subscribed: bool,
303 303
    can_edit: bool,
304 304
    can_triage: bool,
305 +
    /// Projects this issue is on that the viewer may see, with the column.
306 +
    projects: Vec<(crate::db::projects::Project, String)>,
305 307
    notice: Option<String>,
306 308
    error: Option<String>,
307 309
}
…
50 unchanged lines
358 360
    };
359 361
    let can_triage = role >= Role::Triage;
360 362
    let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage;
363 +
    let projects = super::projects::placements_for(app, viewer, &row, i.number).await;
361 364
    Ok(Page(IssuePage {
362 365
        shell,
363 366
        repo: RepoShell::new(app, &row, "issues").with_role(role),
…
9 unchanged lines
373 376
        subscribed,
374 377
        can_edit,
375 378
        can_triage,
379 +
        projects,
376 380
        notice,
377 381
        error,
378 382
    }))
…
576 unchanged lines
modified crates/hub/src/web/mod.rs +3 −0
…
14 unchanged lines
15 15
mod inbox;
16 16
pub mod issues;
17 17
mod orgs;
18 +
mod projects;
18 19
pub mod proposals;
19 20
mod recovery;
20 21
mod refs;
…
70 unchanged lines
91 92
        .route("/{owner}/{name}/changes/{r}", get(history::history))
92 93
        .route("/{owner}/{name}/change/{hash}", get(history::change))
93 94
        .route("/help", get(help::help))
95 +
        .route("/projects/{owner}", get(projects::list).post(projects::create))
96 +
        .route("/projects/{owner}/{number}", get(projects::show).post(projects::act))
94 97
        .route("/notifications/thread", post(inbox::thread))
95 98
        .route("/notifications/open/{id}", get(inbox::open))
96 99
        .route("/login/reset", get(recovery::request_form).post(recovery::request))
…
472 unchanged lines
modified crates/hub/src/web/proposals.rs +4 −0
…
580 unchanged lines
581 581
    all_labels: Vec<db::issues::Label>,
582 582
    checks: Vec<db::hooks::Check>,
583 583
    required_checks: Vec<String>,
584 +
    /// Projects this proposal is on that the viewer may see, with the column.
585 +
    projects: Vec<(db::projects::Project, String)>,
584 586
}
585 587
586 588
#[derive(Deserialize)]
…
77 unchanged lines
664 666
        "changes" => format!("{} changes", p.source_changes().len()),
665 667
        _ => p.source_bookmark.clone(),
666 668
    };
669 +
    let projects = super::projects::placements_for(app, viewer, &row, p.number).await;
667 670
    Ok(Page(ProposalPage {
671 +
        projects,
668 672
        shell,
669 673
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
670 674
        body_html: super::issues::markdown(app, row.id, &format!("/{owner}/{name}"), &p.body).await,
…
498 unchanged lines
modified crates/hub/tests/issues.rs +80 −0
…
705 unchanged lines
706 706
    let stable = get(app, "/andy/demo/proposals?q=target:stable", Some(&andy)).await.body;
707 707
    assert!(stable.contains("Release prep") && !stable.contains("New theme"));
708 708
}
709 +
710 +
#[tokio::test(flavor = "multi_thread")]
711 +
async fn project_boards() {
712 +
    let h = hub().await;
713 +
    let app = &h.app;
714 +
    let andy = register(app, "andy").await;
715 +
    let bob = register(app, "bob").await;
716 +
    for (name, vis) in [("demo", hub_api::Visibility::Public), ("secret", hub_api::Visibility::Private)] {
717 +
        app.repos.create(&RepoName::parse(&format!("andy/{name}")).unwrap(), None).unwrap();
718 +
        let row = db::repos::create(&app.db, "andy", name, "", vis).await.unwrap();
719 +
        db::issues::create(
720 +
            &app.db,
721 +
            db::issues::NewIssue { repo_id: row.id, title: &format!("{name} issue"), body: "", author_id: None, author: "andy" },
722 +
        )
723 +
        .await
724 +
        .unwrap();
725 +
    }
726 +
    let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body);
727 +
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap&description=Next+steps")).await;
728 +
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"), "{}", r.body);
729 +
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
730 +
    for col in ["Backlog", "Doing", "Done"] {
731 +
        assert!(board.contains(&format!(">{col} <span")), "{col}");
732 +
    }
733 +
    let cols: Vec<i64> = sqlx::query_scalar("SELECT id FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap();
734 +
    let act = |form: String| {
735 +
        let andy = andy.clone();
736 +
        async move { post(app, "/projects/andy/1", Some(&andy), &form).await }
737 +
    };
738 +
    // Cards: by reference, by repository and number, a note; duplicates and strangers refused.
739 +
    assert_eq!(
740 +
        act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[0])).await.location.as_deref(),
741 +
        Some("/projects/andy/1")
742 +
    );
743 +
    act(format!("_csrf={csrf}&action=add&column={}&repo=secret&reference=%231", cols[0])).await;
744 +
    act(format!("_csrf={csrf}&action=add&column={}&note=Write+the+docs", cols[1])).await;
745 +
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%231", cols[1])).await.body.contains("already on this project"));
746 +
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=demo%2399", cols[0])).await.body.contains("no issue or proposal #99"));
747 +
    assert!(act(format!("_csrf={csrf}&action=add&column={}&reference=nothere%231", cols[0])).await.body.contains("no repository nothere"));
748 +
    // Move demo#1 to Done.
749 +
    let demo_card: i64 =
750 +
        sqlx::query_scalar("SELECT c.id FROM project_cards c JOIN repositories r ON r.id = c.repo_id WHERE r.name = 'demo'")
751 +
            .fetch_one(&app.db)
752 +
            .await
753 +
            .unwrap();
754 +
    act(format!("_csrf={csrf}&action=move&card={demo_card}&column={}", cols[2])).await;
755 +
    let issue = get(app, "/andy/demo/issue/1", None).await.body;
756 +
    assert!(issue.contains("href=\"/projects/andy/1\">Roadmap</a> <span class=\"mute\">· Done"), "the issue shows where it is");
757 +
758 +
    // Readers see public cards, never those of repositories they cannot read.
759 +
    let anon = get(app, "/projects/andy/1", None).await.body;
760 +
    assert!(anon.contains("demo issue") && anon.contains("Write the docs") && !anon.contains("secret issue"), "{anon}");
761 +
    assert!(!anon.contains("name=\"action\""), "and cannot change anything");
762 +
    assert!(get(app, "/projects/andy/1", Some(&andy)).await.body.contains("secret issue"));
763 +
    // Only the owner changes it; a private project is invisible to others.
764 +
    let csrf_b = csrf_of(&get(app, "/settings", Some(&bob)).await.body);
765 +
    assert_eq!(
766 +
        post(app, "/projects/andy/1", Some(&bob), &format!("_csrf={csrf_b}&action=remove&card={demo_card}")).await.status,
767 +
        StatusCode::NOT_FOUND
768 +
    );
769 +
    act(format!("_csrf={csrf}&action=settings&title=Roadmap&description=&private=1")).await;
770 +
    assert_eq!(get(app, "/projects/andy/1", Some(&bob)).await.status, StatusCode::NOT_FOUND);
771 +
    assert!(!get(app, "/projects/andy", None).await.body.contains("Roadmap"), "not even listed");
772 +
    assert!(!get(app, "/andy/demo/issue/1", None).await.body.contains("Roadmap"), "nor on the issue");
773 +
774 +
    // Columns: removing one moves its cards to the first column; the last one stays.
775 +
    act(format!("_csrf={csrf}&action=remove_column&column={}", cols[1])).await;
776 +
    let note_col: i64 = sqlx::query_scalar("SELECT column_id FROM project_cards WHERE kind = 'note'").fetch_one(&app.db).await.unwrap();
777 +
    assert_eq!(note_col, cols[0]);
778 +
    act(format!("_csrf={csrf}&action=remove_column&column={}", cols[2])).await;
779 +
    assert!(act(format!("_csrf={csrf}&action=remove_column&column={}", cols[0])).await.body.contains("at least one column"));
780 +
    // Closed projects are read-only until reopened; then deleted, the issues remain.
781 +
    act(format!("_csrf={csrf}&action=settings&title=Roadmap&closed=1")).await;
782 +
    assert!(act(format!("_csrf={csrf}&action=add&column={}&note=late", cols[0])).await.body.contains("This project is closed"));
783 +
    let r = act(format!("_csrf={csrf}&action=delete")).await;
784 +
    assert_eq!(r.location.as_deref(), Some("/projects/andy"));
785 +
    assert_eq!(get(app, "/projects/andy/1", Some(&andy)).await.status, StatusCode::NOT_FOUND);
786 +
    assert_eq!(get(app, "/andy/demo/issue/1", None).await.status, StatusCode::OK);
787 +
    assert_eq!(get(app, "/projects/nobody", None).await.status, StatusCode::NOT_FOUND);
788 +
}
modified docs/spec.md +9 −0
…
255 unchanged lines
256 256
| `/{owner}/{name}/edit/{ref}/{path}`, `/new/{ref}/{path}` | Web edit (§6.9) |
257 257
| `/settings/*` | Profile, security (password, TOTP, sessions), keys (linked identities), tokens, notifications, theme |
258 258
| `/notifications` | Inbox (§10) |
259 +
| `/projects/{owner}`, `/projects/{owner}/{n}` | Projects of a person or organisation (§8.2) |
259 260
| `/search` | §11 |
260 261
| `/help` | Getting started with this hub's own address, remote name and certificate filled in (identity, pushing, cloning, proposals, delegating to the hub, pipelines and stamps), the keyboard, and a glossary for people coming from GitHub. Linked from the header band, the footer and the keyboard sheet |
261 262
| `/admin/*` | Site: accounts, registration mode, storage, jobs, audit log |
…
109 unchanged lines
371 372
Cross references: `#12` links an issue or proposal; `owner/name#12` across repositories; a 12-hex-or-longer token links a change when it resolves; `closes #12` in a change message closes the issue when the change lands on the default bookmark. Every reference creates a timeline entry on the target.
372 373
373 374
Import from GitHub or GitLab is a 1.x item.
375 +
376 +
### 8.2 Projects
377 +
378 +
A **project** is a board that plans work across the repositories of one owner (a person or an organisation): a number per owner, a title, a description, public or private, open or closed, and ordered **columns** (new projects start with Backlog, Doing and Done). A column holds ordered **cards**; a card is an issue or proposal of one of the owner's repositories, or a note.
379 +
380 +
- Who: anyone may see a public project; a private one only its owner (for an organisation, its members) and site administrators. The owner, an organisation's members and administrators may change it. A card for an issue or proposal whose repository the viewer cannot read is not shown at all.
381 +
- The board works without JavaScript: each card has a column picker and up and down buttons; cards are added by reference (`#12` in a named repository, or `repo#12`) or as a note; an issue or proposal is on a project at most once. Issue and proposal pages list the projects they are on, with their column.
382 +
- Columns can be added, renamed, moved and removed (removing one moves its cards to the first column). Closing a project keeps it read-only.
374 383
375 384
## 9. Releases and tags
376 385
…
191 unchanged lines
modified templates/issue.html +2 −0
…
52 unchanged lines
53 53
  <h3>Milestone</h3>
54 54
  {% if let Some(ms) = milestone %}<p><a href="{{ repo.url }}/milestones">{{ ms.title }}</a></p>{% else %}<p class="mute small">None.</p>{% endif %}
55 55
  {% if can_triage && !milestones.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/milestone"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="milestone"><option value="">none</option>{% for (ms, sel) in milestones %}<option value="{{ ms.id }}"{% if *sel %} selected{% endif %}>{{ ms.title }}</option>{% endfor %}</select> <button type="submit" class="small">SET</button></form>{% endif %}
56 +
  <h3>Projects</h3>
57 +
  {% if projects.is_empty() %}<p class="mute small">None. <a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% else %}<ul class="plain small">{% for (p, col) in projects %}<li><a href="/projects/{{ p.owner }}/{{ p.number }}">{{ p.title }}</a> <span class="mute">· {{ col }}</span></li>{% endfor %}</ul>{% endif %}
56 58
  {% if can_triage %}
57 59
  <h3>Moderation</h3>
58 60
  <form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
9 unchanged lines
modified templates/org.html +1 −0
…
10 unchanged lines
11 11
    <h3>Members <small>{{ members.len() }}</small></h3>
12 12
    <ul class="plain">{% for (m, role) in members %}<li><img class="avatar xs" src="/avatar/{{ m.username }}" alt=""> <a href="/{{ m.username }}">{{ m.username }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul>
13 13
    {% if !teams.is_empty() %}
14 +
    <p><a class="go small" href="/projects/{{ org.name }}">Projects</a></p>
14 15
    <h3>Teams</h3>
15 16
    <ul class="plain">{% for (t, n) in teams %}<li>{{ t.name }} <span class="small mute">{{ n|plural("member", "members") }}</span></li>{% endfor %}</ul>
16 17
    {% endif %}
…
9 unchanged lines
modified templates/profile.html +1 −0
…
13 unchanged lines
14 14
    </ul>
15 15
    {% if is_me %}<a class="go small" href="/settings">edit profile</a>{% endif %}
16 16
    {% if !orgs.is_empty() %}
17 +
    <p><a class="go small" href="/projects/{{ account.username }}">Projects</a></p>
17 18
    <h3>Organisations</h3>
18 19
    <ul class="plain">{% for (o, role) in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul>
19 20
    {% endif %}
…
12 unchanged lines
modified templates/proposal.html +2 −0
…
115 unchanged lines
116 116
  <h3>Labels</h3>
117 117
  {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span> {% endfor %}</p>{% endif %}
118 118
  {% if repo.can(crate::auth::Access::Write) && !all_labels.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% for l in all_labels %}<label class="small"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> {{ l.name }}</label> {% endfor %}<button type="submit" class="small">SET</button></form>{% endif %}
119 +
  <h3>Projects</h3>
120 +
  {% if projects.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span></li>{% endfor %}</ul>{% endif %}
119 121
  <h3>Checks</h3>
120 122
  <div id="checks">
121 123
  {% if checks.is_empty() %}<p class="mute small">None reported on the projected state{% if !required_checks.is_empty() %}; required: {{ required_checks.join(", ") }}{% endif %}.</p>{% else %}
…
31 unchanged lines
modified templates/style.css +19 −0
…
489 unchanged lines
490 490
  header .find { display: none; }
491 491
}
492 492
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }
493 +
494 +
/* Projects (hub spec §8.2). */
495 +
.board { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
496 +
.board-col { flex: 0 0 290px; background: var(--soft); border: 1px solid var(--line); padding: 8px; }
497 +
.board-col > header { display: flex; justify-content: space-between; align-items: center; gap: 6px; }
498 +
.board-col h2 { font-size: 14px; margin: 2px 0 8px; }
499 +
.board-col ul.cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; min-height: 12px; }
500 +
.card-item { background: var(--panel); border: 1px solid var(--line); padding: 8px 10px; }
501 +
.card-item.note { border-left: 3px solid var(--warn); }
502 +
.card-item p { margin: 4px 0 0; }
503 +
.card-item .card-actions { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
504 +
.card-item .card-actions select { width: auto; margin: 0; padding: 1px 4px; font-size: 12px; }
505 +
.board .add-card { margin-top: 8px; }
506 +
.board .add-card form { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
507 +
.board .inline-fields { display: flex; gap: 4px; }
508 +
.board .inline-fields select { width: 40%; }
509 +
.board .add-col form { display: flex; flex-direction: column; gap: 6px; }
510 +
.state-open { color: var(--ok); } .state-closed, .state-landed { color: var(--acc); }
511 +
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }