Milestone 0.4: issues and notifications 18e381ea88c2
Issues share the number space with proposals; comments, timeline events, close with a reason, reopen, lock, pin; labels on issues and proposals, assignees, milestones, list filters, templates from .oxyhub. Cross references (#N, owner/name#N, @user, change hashes) become links outside code; closes #N in a change landed on the default bookmark closes the issue from pushes and hub landings. An inbox with an unread count, thread subscriptions, watching, mentions, optional email with one-click unsubscribe. FTS5 search over issues and proposals with snippets, plus repositories and accounts, limited to what the viewer may see.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 19:10 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
18e381ea88c294f19fcb8b14534e79555c91d2145018a7ed15b7a5787aa92f2d- Depends on
- 19bb31ff1f5c 62d6e969f180 d23464c0ac60 de505df5cf8d
- Depended on by
- 40a43921ef42 f2d2776b2a14 0bc95135954d aeed63c00ad7 046e3352cdfd b951c58734e8 1000b40a2629 946fe7a09faa 8bb7baeb4557 439ecd8708a1 6a306cd03fc1 657e7d5c9da9 4f8404093d30 ca0c443c30cf dd327d76062d 166f6e9bf4f1 c7067c7bda56 33d98147511b e57765dee61a 0ee5c3fb32eb 86a69b53aff0 16ee5d19d220 a10446c68043 8a48dd830178 965b950f9b96 3eb491e9489b c8684d44d549 3b1bc27fe805 97b18869acf5 ea14c4ec5c3f d1bf2d698777 5d90e0e045e2 7e01a7665aa5 0024ccb1b050 9a777fec4ed9 1966661b1880 2a0c09c09716 ffb67a81188c 2bbdc0268c23 00ef7b83b340 b91e2d81b3f1 bff9106edcba cd46e56b1709 7b9f4570daf5 d3b048b27277 1e8a6e8bb91b 93501da2a162 da8f7826a645 6ae3fa0af4f6 d7d182467382 6246d1e51f4f 0a3b244b748f 61ecbd299f22 7f283f8e8b97 33bd0bd45f8a 17ca6cddc534 f8cf54e99879 c9aacc0e7dc1 acc85ac0f70b f677d6270298 92a797b20912 b1e92a6233ee 92cea629fdf6 7b26e6118bc0 a024e800af4e b210727578e8 1f78655a0466 66fd9d6b1452 d0fbea2eb46e d1727c573f30 5ea4872f23e8 8a3334b7a364 348f8c4baccf 9e50caedf4db ea85398a51bc 8e96499f2705 cb71e18eb6f9 070a7b64b368 d98a93c10a0d 7974adb0be66 df15b74774ca e9bfea8e26db a1b9869565aa 38833ed78707 1f1eb06a9009 9008bf6c2b8c 9756c67d3aec c79a1111dd56 e1ec9fbf538e 78001759604c a160b98fb922 eb0c44a9ad37 34beb1a7e376 43e8ca33bc5d 736f6813543d 42fa95540468 8d2caf122517 1ac84c62ba53 1553bfc9ae53 07b1a5bbf481 9df855f29948 101740454617 c40c2d3edb51 4edcf24025cf 25ab8c73514a 3f4b0ea6eabf 145306044896 b64e2537d702
- 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
- 78 atoms, 16 tree operations, 35 files
Browse the tree at this change
added crates/hub/migrations/0005_issues.sql +114 −0
| 1 | + | -- Issues, labels, milestones, notifications, watching and search |
|
| 2 | + | -- (hub spec §8, §10, §11). |
|
| 3 | + | ||
| 4 | + | CREATE TABLE issues ( |
|
| 5 | + | id INTEGER PRIMARY KEY, |
|
| 6 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 7 | + | number INTEGER NOT NULL, |
|
| 8 | + | title TEXT NOT NULL, |
|
| 9 | + | body TEXT NOT NULL DEFAULT '', |
|
| 10 | + | author_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 11 | + | author TEXT NOT NULL DEFAULT '', |
|
| 12 | + | state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')), |
|
| 13 | + | reason TEXT NOT NULL DEFAULT '' CHECK (reason IN ('', 'completed', 'not_planned')), |
|
| 14 | + | locked INTEGER NOT NULL DEFAULT 0, |
|
| 15 | + | pinned INTEGER NOT NULL DEFAULT 0, |
|
| 16 | + | milestone_id INTEGER REFERENCES milestones(id) ON DELETE SET NULL, |
|
| 17 | + | created_ms INTEGER NOT NULL, |
|
| 18 | + | updated_ms INTEGER NOT NULL, |
|
| 19 | + | closed_ms INTEGER, |
|
| 20 | + | UNIQUE (repo_id, number) |
|
| 21 | + | ); |
|
| 22 | + | ||
| 23 | + | CREATE TABLE issue_comments ( |
|
| 24 | + | id INTEGER PRIMARY KEY, |
|
| 25 | + | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, |
|
| 26 | + | author_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 27 | + | author TEXT NOT NULL DEFAULT '', |
|
| 28 | + | body TEXT NOT NULL, |
|
| 29 | + | created_ms INTEGER NOT NULL, |
|
| 30 | + | edited_ms INTEGER |
|
| 31 | + | ); |
|
| 32 | + | CREATE INDEX issue_comments_by_issue ON issue_comments (issue_id, created_ms); |
|
| 33 | + | ||
| 34 | + | CREATE TABLE issue_events ( |
|
| 35 | + | id INTEGER PRIMARY KEY, |
|
| 36 | + | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, |
|
| 37 | + | at_ms INTEGER NOT NULL, |
|
| 38 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 39 | + | event TEXT NOT NULL, |
|
| 40 | + | detail TEXT NOT NULL DEFAULT '' |
|
| 41 | + | ); |
|
| 42 | + | ||
| 43 | + | CREATE TABLE labels ( |
|
| 44 | + | id INTEGER PRIMARY KEY, |
|
| 45 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 46 | + | name TEXT NOT NULL COLLATE NOCASE, |
|
| 47 | + | colour TEXT NOT NULL DEFAULT '#0072ff', |
|
| 48 | + | description TEXT NOT NULL DEFAULT '', |
|
| 49 | + | UNIQUE (repo_id, name) |
|
| 50 | + | ); |
|
| 51 | + | ||
| 52 | + | CREATE TABLE issue_labels ( |
|
| 53 | + | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, |
|
| 54 | + | label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, |
|
| 55 | + | PRIMARY KEY (issue_id, label_id) |
|
| 56 | + | ); |
|
| 57 | + | ||
| 58 | + | CREATE TABLE issue_assignees ( |
|
| 59 | + | issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, |
|
| 60 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 61 | + | PRIMARY KEY (issue_id, account_id) |
|
| 62 | + | ); |
|
| 63 | + | ||
| 64 | + | CREATE TABLE milestones ( |
|
| 65 | + | id INTEGER PRIMARY KEY, |
|
| 66 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 67 | + | title TEXT NOT NULL, |
|
| 68 | + | description TEXT NOT NULL DEFAULT '', |
|
| 69 | + | due_ms INTEGER, |
|
| 70 | + | state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')), |
|
| 71 | + | UNIQUE (repo_id, title) |
|
| 72 | + | ); |
|
| 73 | + | ||
| 74 | + | -- Proposals get labels too (hub spec §7.1). |
|
| 75 | + | CREATE TABLE proposal_labels ( |
|
| 76 | + | proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, |
|
| 77 | + | label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, |
|
| 78 | + | PRIMARY KEY (proposal_id, label_id) |
|
| 79 | + | ); |
|
| 80 | + | ||
| 81 | + | -- The inbox (hub spec §10). |
|
| 82 | + | CREATE TABLE notifications ( |
|
| 83 | + | id INTEGER PRIMARY KEY, |
|
| 84 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 85 | + | at_ms INTEGER NOT NULL, |
|
| 86 | + | kind TEXT NOT NULL, |
|
| 87 | + | title TEXT NOT NULL, |
|
| 88 | + | url TEXT NOT NULL, |
|
| 89 | + | actor TEXT NOT NULL DEFAULT '', |
|
| 90 | + | read INTEGER NOT NULL DEFAULT 0 |
|
| 91 | + | ); |
|
| 92 | + | CREATE INDEX notifications_by_account ON notifications (account_id, read, at_ms DESC); |
|
| 93 | + | ||
| 94 | + | -- Watching a repository subscribes to all its events; participating in a |
|
| 95 | + | -- thread subscribes to it. |
|
| 96 | + | CREATE TABLE watches ( |
|
| 97 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 98 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 99 | + | PRIMARY KEY (account_id, repo_id) |
|
| 100 | + | ); |
|
| 101 | + | ||
| 102 | + | CREATE TABLE subscriptions ( |
|
| 103 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 104 | + | kind TEXT NOT NULL CHECK (kind IN ('issue', 'proposal')), |
|
| 105 | + | target_id INTEGER NOT NULL, |
|
| 106 | + | PRIMARY KEY (account_id, kind, target_id) |
|
| 107 | + | ); |
|
| 108 | + | ||
| 109 | + | -- Per-account notification preferences. |
|
| 110 | + | ALTER TABLE accounts ADD COLUMN email_notifications INTEGER NOT NULL DEFAULT 0; |
|
| 111 | + | ALTER TABLE accounts ADD COLUMN unsubscribe_key TEXT NOT NULL DEFAULT ''; |
|
| 112 | + | ||
| 113 | + | -- Full-text search over issues, proposals and their comments (§11). |
|
| 114 | + | CREATE VIRTUAL TABLE search USING fts5(kind UNINDEXED, repo_id UNINDEXED, target_id UNINDEXED, number UNINDEXED, title, body, tokenize = 'porter unicode61'); |
added crates/hub/src/db/issues.rs +372 −0
| 1 | + | //! Issues, labels, milestones and their events (hub spec §8). |
|
| 2 | + | ||
| 3 | + | use super::{now_ms, Pool}; |
|
| 4 | + | use sqlx::FromRow; |
|
| 5 | + | ||
| 6 | + | #[derive(Clone, Debug, FromRow)] |
|
| 7 | + | pub struct IssueRow { |
|
| 8 | + | pub id: i64, |
|
| 9 | + | pub repo_id: i64, |
|
| 10 | + | pub number: i64, |
|
| 11 | + | pub title: String, |
|
| 12 | + | pub body: String, |
|
| 13 | + | pub author_id: Option<i64>, |
|
| 14 | + | pub author: String, |
|
| 15 | + | pub state: String, |
|
| 16 | + | pub reason: String, |
|
| 17 | + | pub locked: bool, |
|
| 18 | + | pub pinned: bool, |
|
| 19 | + | pub milestone_id: Option<i64>, |
|
| 20 | + | pub created_ms: i64, |
|
| 21 | + | pub updated_ms: i64, |
|
| 22 | + | pub closed_ms: Option<i64>, |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | #[derive(Clone, Debug, FromRow)] |
|
| 26 | + | pub struct IssueComment { |
|
| 27 | + | pub id: i64, |
|
| 28 | + | pub issue_id: i64, |
|
| 29 | + | pub author_id: Option<i64>, |
|
| 30 | + | pub author: String, |
|
| 31 | + | pub body: String, |
|
| 32 | + | pub created_ms: i64, |
|
| 33 | + | pub edited_ms: Option<i64>, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | #[derive(Clone, Debug, FromRow)] |
|
| 37 | + | pub struct IssueEvent { |
|
| 38 | + | pub id: i64, |
|
| 39 | + | pub at_ms: i64, |
|
| 40 | + | pub actor: String, |
|
| 41 | + | pub event: String, |
|
| 42 | + | pub detail: String, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | #[derive(Clone, Debug, FromRow, PartialEq, Eq)] |
|
| 46 | + | pub struct Label { |
|
| 47 | + | pub id: i64, |
|
| 48 | + | pub repo_id: i64, |
|
| 49 | + | pub name: String, |
|
| 50 | + | pub colour: String, |
|
| 51 | + | pub description: String, |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | #[derive(Clone, Debug, FromRow)] |
|
| 55 | + | pub struct Milestone { |
|
| 56 | + | pub id: i64, |
|
| 57 | + | pub repo_id: i64, |
|
| 58 | + | pub title: String, |
|
| 59 | + | pub description: String, |
|
| 60 | + | pub due_ms: Option<i64>, |
|
| 61 | + | pub state: String, |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | pub struct NewIssue<'a> { |
|
| 65 | + | pub repo_id: i64, |
|
| 66 | + | pub title: &'a str, |
|
| 67 | + | pub body: &'a str, |
|
| 68 | + | pub author_id: Option<i64>, |
|
| 69 | + | pub author: &'a str, |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | pub async fn create(pool: &Pool, i: NewIssue<'_>) -> sqlx::Result<IssueRow> { |
|
| 73 | + | let number = super::proposals::next_number(pool, i.repo_id).await?; |
|
| 74 | + | let now = now_ms(); |
|
| 75 | + | sqlx::query_as("INSERT INTO issues (repo_id, number, title, body, author_id, author, created_ms, updated_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *") |
|
| 76 | + | .bind(i.repo_id) |
|
| 77 | + | .bind(number) |
|
| 78 | + | .bind(i.title) |
|
| 79 | + | .bind(i.body) |
|
| 80 | + | .bind(i.author_id) |
|
| 81 | + | .bind(i.author) |
|
| 82 | + | .bind(now) |
|
| 83 | + | .bind(now) |
|
| 84 | + | .fetch_one(pool) |
|
| 85 | + | .await |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | pub async fn get(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Option<IssueRow>> { |
|
| 89 | + | sqlx::query_as("SELECT * FROM issues WHERE repo_id = ? AND number = ?").bind(repo_id).bind(number).fetch_optional(pool).await |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | pub async fn get_by_id(pool: &Pool, id: i64) -> sqlx::Result<Option<IssueRow>> { |
|
| 93 | + | sqlx::query_as("SELECT * FROM issues WHERE id = ?").bind(id).fetch_optional(pool).await |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | pub async fn list(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<IssueRow>> { |
|
| 97 | + | sqlx::query_as("SELECT * FROM issues WHERE repo_id = ? ORDER BY pinned DESC, number DESC").bind(repo_id).fetch_all(pool).await |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | /// Numbers in use by issues and proposals, for cross references. |
|
| 101 | + | pub async fn numbers(pool: &Pool, repo_id: i64) -> sqlx::Result<(Vec<i64>, Vec<i64>)> { |
|
| 102 | + | let issues: Vec<i64> = sqlx::query_scalar("SELECT number FROM issues WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await?; |
|
| 103 | + | let proposals: Vec<i64> = sqlx::query_scalar("SELECT number FROM proposals WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await?; |
|
| 104 | + | Ok((issues, proposals)) |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | pub async fn assigned_to(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<IssueRow>> { |
|
| 108 | + | sqlx::query_as("SELECT i.* FROM issues i JOIN issue_assignees a ON a.issue_id = i.id WHERE a.account_id = ? AND i.state = 'open' ORDER BY i.updated_ms DESC") |
|
| 109 | + | .bind(account_id) |
|
| 110 | + | .fetch_all(pool) |
|
| 111 | + | .await |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | pub async fn edit(pool: &Pool, id: i64, title: &str, body: &str) -> sqlx::Result<()> { |
|
| 115 | + | sqlx::query("UPDATE issues SET title = ?, body = ?, updated_ms = ? WHERE id = ?") |
|
| 116 | + | .bind(title) |
|
| 117 | + | .bind(body) |
|
| 118 | + | .bind(now_ms()) |
|
| 119 | + | .bind(id) |
|
| 120 | + | .execute(pool) |
|
| 121 | + | .await?; |
|
| 122 | + | Ok(()) |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | pub async fn set_state(pool: &Pool, id: i64, state: &str, reason: &str) -> sqlx::Result<()> { |
|
| 126 | + | let now = now_ms(); |
|
| 127 | + | let closed: Option<i64> = if state == "closed" { Some(now) } else { None }; |
|
| 128 | + | sqlx::query("UPDATE issues SET state = ?, reason = ?, closed_ms = ?, updated_ms = ? WHERE id = ?") |
|
| 129 | + | .bind(state) |
|
| 130 | + | .bind(reason) |
|
| 131 | + | .bind(closed) |
|
| 132 | + | .bind(now) |
|
| 133 | + | .bind(id) |
|
| 134 | + | .execute(pool) |
|
| 135 | + | .await?; |
|
| 136 | + | Ok(()) |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | pub async fn set_flags(pool: &Pool, id: i64, locked: Option<bool>, pinned: Option<bool>) -> sqlx::Result<()> { |
|
| 140 | + | if let Some(l) = locked { |
|
| 141 | + | sqlx::query("UPDATE issues SET locked = ? WHERE id = ?").bind(l).bind(id).execute(pool).await?; |
|
| 142 | + | } |
|
| 143 | + | if let Some(p) = pinned { |
|
| 144 | + | sqlx::query("UPDATE issues SET pinned = ? WHERE id = ?").bind(p).bind(id).execute(pool).await?; |
|
| 145 | + | } |
|
| 146 | + | Ok(()) |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | pub async fn set_milestone(pool: &Pool, id: i64, milestone_id: Option<i64>) -> sqlx::Result<()> { |
|
| 150 | + | sqlx::query("UPDATE issues SET milestone_id = ?, updated_ms = ? WHERE id = ?") |
|
| 151 | + | .bind(milestone_id) |
|
| 152 | + | .bind(now_ms()) |
|
| 153 | + | .bind(id) |
|
| 154 | + | .execute(pool) |
|
| 155 | + | .await?; |
|
| 156 | + | Ok(()) |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | pub async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 160 | + | sqlx::query("UPDATE issues SET updated_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?; |
|
| 161 | + | Ok(()) |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | pub async fn comments(pool: &Pool, issue_id: i64) -> sqlx::Result<Vec<IssueComment>> { |
|
| 165 | + | sqlx::query_as("SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_ms, id").bind(issue_id).fetch_all(pool).await |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | pub async fn add_comment(pool: &Pool, issue_id: i64, author_id: Option<i64>, author: &str, body: &str) -> sqlx::Result<IssueComment> { |
|
| 169 | + | let row = |
|
| 170 | + | sqlx::query_as("INSERT INTO issue_comments (issue_id, author_id, author, body, created_ms) VALUES (?, ?, ?, ?, ?) RETURNING *") |
|
| 171 | + | .bind(issue_id) |
|
| 172 | + | .bind(author_id) |
|
| 173 | + | .bind(author) |
|
| 174 | + | .bind(body) |
|
| 175 | + | .bind(now_ms()) |
|
| 176 | + | .fetch_one(pool) |
|
| 177 | + | .await?; |
|
| 178 | + | touch(pool, issue_id).await?; |
|
| 179 | + | Ok(row) |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | pub async fn edit_comment(pool: &Pool, id: i64, author_id: i64, body: &str) -> sqlx::Result<bool> { |
|
| 183 | + | let n = sqlx::query("UPDATE issue_comments SET body = ?, edited_ms = ? WHERE id = ? AND author_id = ?") |
|
| 184 | + | .bind(body) |
|
| 185 | + | .bind(now_ms()) |
|
| 186 | + | .bind(id) |
|
| 187 | + | .bind(author_id) |
|
| 188 | + | .execute(pool) |
|
| 189 | + | .await?; |
|
| 190 | + | Ok(n.rows_affected() > 0) |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | pub async fn delete_comment(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 194 | + | sqlx::query("DELETE FROM issue_comments WHERE id = ?").bind(id).execute(pool).await?; |
|
| 195 | + | Ok(()) |
|
| 196 | + | } |
|
| 197 | + | ||
| 198 | + | pub async fn events(pool: &Pool, issue_id: i64) -> sqlx::Result<Vec<IssueEvent>> { |
|
| 199 | + | sqlx::query_as("SELECT id, at_ms, actor, event, detail FROM issue_events WHERE issue_id = ? ORDER BY at_ms, id") |
|
| 200 | + | .bind(issue_id) |
|
| 201 | + | .fetch_all(pool) |
|
| 202 | + | .await |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | pub async fn add_event(pool: &Pool, issue_id: i64, actor: &str, event: &str, detail: &str) -> sqlx::Result<()> { |
|
| 206 | + | sqlx::query("INSERT INTO issue_events (issue_id, at_ms, actor, event, detail) VALUES (?, ?, ?, ?, ?)") |
|
| 207 | + | .bind(issue_id) |
|
| 208 | + | .bind(now_ms()) |
|
| 209 | + | .bind(actor) |
|
| 210 | + | .bind(event) |
|
| 211 | + | .bind(detail) |
|
| 212 | + | .execute(pool) |
|
| 213 | + | .await?; |
|
| 214 | + | Ok(()) |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | // ------------------------------------------------------------------ labels |
|
| 218 | + | ||
| 219 | + | pub async fn labels(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Label>> { |
|
| 220 | + | sqlx::query_as("SELECT * FROM labels WHERE repo_id = ? ORDER BY name").bind(repo_id).fetch_all(pool).await |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | pub async fn label_by_name(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Option<Label>> { |
|
| 224 | + | sqlx::query_as("SELECT * FROM labels WHERE repo_id = ? AND name = ?").bind(repo_id).bind(name).fetch_optional(pool).await |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | pub async fn upsert_label(pool: &Pool, repo_id: i64, name: &str, colour: &str, description: &str) -> sqlx::Result<Label> { |
|
| 228 | + | sqlx::query_as("INSERT INTO labels (repo_id, name, colour, description) VALUES (?, ?, ?, ?) ON CONFLICT (repo_id, name) DO UPDATE SET colour = excluded.colour, description = excluded.description RETURNING *") |
|
| 229 | + | .bind(repo_id) |
|
| 230 | + | .bind(name) |
|
| 231 | + | .bind(colour) |
|
| 232 | + | .bind(description) |
|
| 233 | + | .fetch_one(pool) |
|
| 234 | + | .await |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | pub async fn delete_label(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 238 | + | sqlx::query("DELETE FROM labels WHERE id = ?").bind(id).execute(pool).await?; |
|
| 239 | + | Ok(()) |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | pub async fn issue_labels(pool: &Pool, issue_id: i64) -> sqlx::Result<Vec<Label>> { |
|
| 243 | + | sqlx::query_as("SELECT l.* FROM labels l JOIN issue_labels il ON il.label_id = l.id WHERE il.issue_id = ? ORDER BY l.name") |
|
| 244 | + | .bind(issue_id) |
|
| 245 | + | .fetch_all(pool) |
|
| 246 | + | .await |
|
| 247 | + | } |
|
| 248 | + | ||
| 249 | + | pub async fn set_issue_label(pool: &Pool, issue_id: i64, label_id: i64, on: bool) -> sqlx::Result<()> { |
|
| 250 | + | if on { |
|
| 251 | + | sqlx::query("INSERT OR IGNORE INTO issue_labels (issue_id, label_id) VALUES (?, ?)") |
|
| 252 | + | .bind(issue_id) |
|
| 253 | + | .bind(label_id) |
|
| 254 | + | .execute(pool) |
|
| 255 | + | .await?; |
|
| 256 | + | } else { |
|
| 257 | + | sqlx::query("DELETE FROM issue_labels WHERE issue_id = ? AND label_id = ?").bind(issue_id).bind(label_id).execute(pool).await?; |
|
| 258 | + | } |
|
| 259 | + | Ok(()) |
|
| 260 | + | } |
|
| 261 | + | ||
| 262 | + | /// Labels of every issue of a repository, for the list page. |
|
| 263 | + | pub async fn labels_by_issue(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, Label)>> { |
|
| 264 | + | let rows: Vec<(i64, i64, i64, String, String, String)> = sqlx::query_as( |
|
| 265 | + | "SELECT il.issue_id, l.id, l.repo_id, l.name, l.colour, l.description FROM issue_labels il JOIN labels l ON l.id = il.label_id JOIN issues i ON i.id = il.issue_id WHERE i.repo_id = ? ORDER BY l.name", |
|
| 266 | + | ) |
|
| 267 | + | .bind(repo_id) |
|
| 268 | + | .fetch_all(pool) |
|
| 269 | + | .await?; |
|
| 270 | + | Ok(rows |
|
| 271 | + | .into_iter() |
|
| 272 | + | .map(|(issue_id, id, repo_id, name, colour, description)| (issue_id, Label { id, repo_id, name, colour, description })) |
|
| 273 | + | .collect()) |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | pub async fn proposal_labels(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<Label>> { |
|
| 277 | + | sqlx::query_as("SELECT l.* FROM labels l JOIN proposal_labels pl ON pl.label_id = l.id WHERE pl.proposal_id = ? ORDER BY l.name") |
|
| 278 | + | .bind(proposal_id) |
|
| 279 | + | .fetch_all(pool) |
|
| 280 | + | .await |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | pub async fn set_proposal_label(pool: &Pool, proposal_id: i64, label_id: i64, on: bool) -> sqlx::Result<()> { |
|
| 284 | + | if on { |
|
| 285 | + | sqlx::query("INSERT OR IGNORE INTO proposal_labels (proposal_id, label_id) VALUES (?, ?)") |
|
| 286 | + | .bind(proposal_id) |
|
| 287 | + | .bind(label_id) |
|
| 288 | + | .execute(pool) |
|
| 289 | + | .await?; |
|
| 290 | + | } else { |
|
| 291 | + | sqlx::query("DELETE FROM proposal_labels WHERE proposal_id = ? AND label_id = ?") |
|
| 292 | + | .bind(proposal_id) |
|
| 293 | + | .bind(label_id) |
|
| 294 | + | .execute(pool) |
|
| 295 | + | .await?; |
|
| 296 | + | } |
|
| 297 | + | Ok(()) |
|
| 298 | + | } |
|
| 299 | + | ||
| 300 | + | // --------------------------------------------------------------- assignees |
|
| 301 | + | ||
| 302 | + | pub async fn assignees(pool: &Pool, issue_id: i64) -> sqlx::Result<Vec<String>> { |
|
| 303 | + | sqlx::query_scalar( |
|
| 304 | + | "SELECT a.username FROM accounts a JOIN issue_assignees ia ON ia.account_id = a.id WHERE ia.issue_id = ? ORDER BY a.username", |
|
| 305 | + | ) |
|
| 306 | + | .bind(issue_id) |
|
| 307 | + | .fetch_all(pool) |
|
| 308 | + | .await |
|
| 309 | + | } |
|
| 310 | + | ||
| 311 | + | pub async fn set_assignee(pool: &Pool, issue_id: i64, account_id: i64, on: bool) -> sqlx::Result<()> { |
|
| 312 | + | if on { |
|
| 313 | + | sqlx::query("INSERT OR IGNORE INTO issue_assignees (issue_id, account_id) VALUES (?, ?)") |
|
| 314 | + | .bind(issue_id) |
|
| 315 | + | .bind(account_id) |
|
| 316 | + | .execute(pool) |
|
| 317 | + | .await?; |
|
| 318 | + | } else { |
|
| 319 | + | sqlx::query("DELETE FROM issue_assignees WHERE issue_id = ? AND account_id = ?") |
|
| 320 | + | .bind(issue_id) |
|
| 321 | + | .bind(account_id) |
|
| 322 | + | .execute(pool) |
|
| 323 | + | .await?; |
|
| 324 | + | } |
|
| 325 | + | Ok(()) |
|
| 326 | + | } |
|
| 327 | + | ||
| 328 | + | /// Assignees of every issue of a repository, for the list page. |
|
| 329 | + | pub async fn assignees_by_issue(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, String)>> { |
|
| 330 | + | sqlx::query_as("SELECT ia.issue_id, a.username FROM issue_assignees ia JOIN accounts a ON a.id = ia.account_id JOIN issues i ON i.id = ia.issue_id WHERE i.repo_id = ?") |
|
| 331 | + | .bind(repo_id) |
|
| 332 | + | .fetch_all(pool) |
|
| 333 | + | .await |
|
| 334 | + | } |
|
| 335 | + | ||
| 336 | + | // -------------------------------------------------------------- milestones |
|
| 337 | + | ||
| 338 | + | pub async fn milestones(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Milestone>> { |
|
| 339 | + | sqlx::query_as("SELECT * FROM milestones WHERE repo_id = ? ORDER BY state, due_ms, title").bind(repo_id).fetch_all(pool).await |
|
| 340 | + | } |
|
| 341 | + | ||
| 342 | + | pub async fn milestone(pool: &Pool, id: i64) -> sqlx::Result<Option<Milestone>> { |
|
| 343 | + | sqlx::query_as("SELECT * FROM milestones WHERE id = ?").bind(id).fetch_optional(pool).await |
|
| 344 | + | } |
|
| 345 | + | ||
| 346 | + | pub async fn upsert_milestone(pool: &Pool, repo_id: i64, title: &str, description: &str, due_ms: Option<i64>) -> sqlx::Result<Milestone> { |
|
| 347 | + | sqlx::query_as("INSERT INTO milestones (repo_id, title, description, due_ms) VALUES (?, ?, ?, ?) ON CONFLICT (repo_id, title) DO UPDATE SET description = excluded.description, due_ms = excluded.due_ms RETURNING *") |
|
| 348 | + | .bind(repo_id) |
|
| 349 | + | .bind(title) |
|
| 350 | + | .bind(description) |
|
| 351 | + | .bind(due_ms) |
|
| 352 | + | .fetch_one(pool) |
|
| 353 | + | .await |
|
| 354 | + | } |
|
| 355 | + | ||
| 356 | + | pub async fn set_milestone_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> { |
|
| 357 | + | sqlx::query("UPDATE milestones SET state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?; |
|
| 358 | + | Ok(()) |
|
| 359 | + | } |
|
| 360 | + | ||
| 361 | + | /// `(open, closed)` issue counts per milestone. |
|
| 362 | + | pub async fn milestone_counts(pool: &Pool, milestone_id: i64) -> sqlx::Result<(i64, i64)> { |
|
| 363 | + | let open: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE milestone_id = ? AND state = 'open'") |
|
| 364 | + | .bind(milestone_id) |
|
| 365 | + | .fetch_one(pool) |
|
| 366 | + | .await?; |
|
| 367 | + | let closed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issues WHERE milestone_id = ? AND state = 'closed'") |
|
| 368 | + | .bind(milestone_id) |
|
| 369 | + | .fetch_one(pool) |
|
| 370 | + | .await?; |
|
| 371 | + | Ok((open, closed)) |
|
| 372 | + | } |
added crates/hub/src/db/notify.rs +157 −0
| 1 | + | //! Notifications, watching, subscriptions and the search index (hub spec |
|
| 2 | + | //! §10, §11). |
|
| 3 | + | ||
| 4 | + | use super::{now_ms, Pool}; |
|
| 5 | + | use sqlx::FromRow; |
|
| 6 | + | ||
| 7 | + | #[derive(Clone, Debug, FromRow)] |
|
| 8 | + | pub struct Notification { |
|
| 9 | + | pub id: i64, |
|
| 10 | + | pub account_id: i64, |
|
| 11 | + | pub at_ms: i64, |
|
| 12 | + | pub kind: String, |
|
| 13 | + | pub title: String, |
|
| 14 | + | pub url: String, |
|
| 15 | + | pub actor: String, |
|
| 16 | + | pub read: bool, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | pub async fn add(pool: &Pool, account_id: i64, kind: &str, title: &str, url: &str, actor: &str) -> sqlx::Result<()> { |
|
| 20 | + | sqlx::query("INSERT INTO notifications (account_id, at_ms, kind, title, url, actor) VALUES (?, ?, ?, ?, ?, ?)") |
|
| 21 | + | .bind(account_id) |
|
| 22 | + | .bind(now_ms()) |
|
| 23 | + | .bind(kind) |
|
| 24 | + | .bind(title) |
|
| 25 | + | .bind(url) |
|
| 26 | + | .bind(actor) |
|
| 27 | + | .execute(pool) |
|
| 28 | + | .await?; |
|
| 29 | + | Ok(()) |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | pub async fn inbox(pool: &Pool, account_id: i64, limit: i64) -> sqlx::Result<Vec<Notification>> { |
|
| 33 | + | sqlx::query_as("SELECT * FROM notifications WHERE account_id = ? ORDER BY read, at_ms DESC, id DESC LIMIT ?") |
|
| 34 | + | .bind(account_id) |
|
| 35 | + | .bind(limit) |
|
| 36 | + | .fetch_all(pool) |
|
| 37 | + | .await |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | pub async fn unread(pool: &Pool, account_id: i64) -> sqlx::Result<i64> { |
|
| 41 | + | sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE account_id = ? AND read = 0").bind(account_id).fetch_one(pool).await |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | pub async fn mark_read(pool: &Pool, account_id: i64, id: Option<i64>) -> sqlx::Result<()> { |
|
| 45 | + | match id { |
|
| 46 | + | Some(id) => { |
|
| 47 | + | sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ? AND id = ?").bind(account_id).bind(id).execute(pool).await? |
|
| 48 | + | } |
|
| 49 | + | None => sqlx::query("UPDATE notifications SET read = 1 WHERE account_id = ?").bind(account_id).execute(pool).await?, |
|
| 50 | + | }; |
|
| 51 | + | Ok(()) |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | // ---------------------------------------------------------------- watching |
|
| 55 | + | ||
| 56 | + | pub async fn watching(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> { |
|
| 57 | + | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM watches WHERE account_id = ? AND repo_id = ?") |
|
| 58 | + | .bind(account_id) |
|
| 59 | + | .bind(repo_id) |
|
| 60 | + | .fetch_one(pool) |
|
| 61 | + | .await?; |
|
| 62 | + | Ok(n > 0) |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | pub async fn set_watch(pool: &Pool, account_id: i64, repo_id: i64, on: bool) -> sqlx::Result<()> { |
|
| 66 | + | if on { |
|
| 67 | + | sqlx::query("INSERT OR IGNORE INTO watches (account_id, repo_id) VALUES (?, ?)") |
|
| 68 | + | .bind(account_id) |
|
| 69 | + | .bind(repo_id) |
|
| 70 | + | .execute(pool) |
|
| 71 | + | .await?; |
|
| 72 | + | } else { |
|
| 73 | + | sqlx::query("DELETE FROM watches WHERE account_id = ? AND repo_id = ?").bind(account_id).bind(repo_id).execute(pool).await?; |
|
| 74 | + | } |
|
| 75 | + | Ok(()) |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | pub async fn watchers(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> { |
|
| 79 | + | sqlx::query_scalar("SELECT account_id FROM watches WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | pub async fn subscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> { |
|
| 83 | + | sqlx::query("INSERT OR IGNORE INTO subscriptions (account_id, kind, target_id) VALUES (?, ?, ?)") |
|
| 84 | + | .bind(account_id) |
|
| 85 | + | .bind(kind) |
|
| 86 | + | .bind(target_id) |
|
| 87 | + | .execute(pool) |
|
| 88 | + | .await?; |
|
| 89 | + | Ok(()) |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | pub async fn unsubscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> { |
|
| 93 | + | sqlx::query("DELETE FROM subscriptions WHERE account_id = ? AND kind = ? AND target_id = ?") |
|
| 94 | + | .bind(account_id) |
|
| 95 | + | .bind(kind) |
|
| 96 | + | .bind(target_id) |
|
| 97 | + | .execute(pool) |
|
| 98 | + | .await?; |
|
| 99 | + | Ok(()) |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | pub async fn subscribed(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<bool> { |
|
| 103 | + | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE account_id = ? AND kind = ? AND target_id = ?") |
|
| 104 | + | .bind(account_id) |
|
| 105 | + | .bind(kind) |
|
| 106 | + | .bind(target_id) |
|
| 107 | + | .fetch_one(pool) |
|
| 108 | + | .await?; |
|
| 109 | + | Ok(n > 0) |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | pub async fn subscribers(pool: &Pool, kind: &str, target_id: i64) -> sqlx::Result<Vec<i64>> { |
|
| 113 | + | sqlx::query_scalar("SELECT account_id FROM subscriptions WHERE kind = ? AND target_id = ?") |
|
| 114 | + | .bind(kind) |
|
| 115 | + | .bind(target_id) |
|
| 116 | + | .fetch_all(pool) |
|
| 117 | + | .await |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | // ------------------------------------------------------------------ search |
|
| 121 | + | ||
| 122 | + | #[derive(Clone, Debug, FromRow)] |
|
| 123 | + | pub struct Hit { |
|
| 124 | + | pub kind: String, |
|
| 125 | + | pub repo_id: i64, |
|
| 126 | + | pub target_id: i64, |
|
| 127 | + | pub number: i64, |
|
| 128 | + | pub title: String, |
|
| 129 | + | pub snippet: String, |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | /// Replace the indexed text of one thread (title plus body plus comments). |
|
| 133 | + | pub async fn index(pool: &Pool, kind: &str, repo_id: i64, target_id: i64, number: i64, title: &str, body: &str) -> sqlx::Result<()> { |
|
| 134 | + | sqlx::query("DELETE FROM search WHERE kind = ? AND target_id = ?").bind(kind).bind(target_id).execute(pool).await?; |
|
| 135 | + | sqlx::query("INSERT INTO search (kind, repo_id, target_id, number, title, body) VALUES (?, ?, ?, ?, ?, ?)") |
|
| 136 | + | .bind(kind) |
|
| 137 | + | .bind(repo_id) |
|
| 138 | + | .bind(target_id) |
|
| 139 | + | .bind(number) |
|
| 140 | + | .bind(title) |
|
| 141 | + | .bind(body) |
|
| 142 | + | .execute(pool) |
|
| 143 | + | .await?; |
|
| 144 | + | Ok(()) |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | /// Full-text hits, best first. The query is quoted so punctuation cannot |
|
| 148 | + | /// reach the FTS5 parser. |
|
| 149 | + | pub async fn search(pool: &Pool, query: &str, repo_id: Option<i64>, limit: i64) -> sqlx::Result<Vec<Hit>> { |
|
| 150 | + | let terms: Vec<String> = query.split_whitespace().map(|t| format!("\"{}\"", t.replace('"', ""))).collect(); |
|
| 151 | + | if terms.is_empty() { |
|
| 152 | + | return Ok(vec![]); |
|
| 153 | + | } |
|
| 154 | + | let q = terms.join(" "); |
|
| 155 | + | let sql = "SELECT kind, repo_id, target_id, number, title, snippet(search, 5, '<mark>', '</mark>', '…', 12) AS snippet FROM search WHERE search MATCH ? AND (? IS NULL OR repo_id = ?) ORDER BY rank LIMIT ?"; |
|
| 156 | + | sqlx::query_as(sql).bind(q).bind(repo_id).bind(repo_id).bind(limit).fetch_all(pool).await |
|
| 157 | + | } |
added crates/hub/src/notify/mod.rs +107 −0
| 1 | + | //! Notifications (hub spec §10): who to tell about an event, the inbox |
|
| 2 | + | //! row, and email when the account asked for it and SMTP is configured. |
|
| 3 | + | ||
| 4 | + | use crate::db::{self, accounts::Account, notify as ndb}; |
|
| 5 | + | use crate::Shared; |
|
| 6 | + | use std::collections::BTreeSet; |
|
| 7 | + | ||
| 8 | + | /// An event worth telling people about. |
|
| 9 | + | pub struct Event<'a> { |
|
| 10 | + | pub kind: &'a str, |
|
| 11 | + | pub title: String, |
|
| 12 | + | pub url: String, |
|
| 13 | + | pub actor: &'a Account, |
|
| 14 | + | pub repo_id: i64, |
|
| 15 | + | /// `issue` or `proposal` and its id, for thread subscribers. |
|
| 16 | + | pub thread: Option<(&'a str, i64)>, |
|
| 17 | + | /// Bodies to scan for `@mentions`. |
|
| 18 | + | pub mention_text: &'a str, |
|
| 19 | + | /// Accounts to tell whatever their subscriptions (author, assignees). |
|
| 20 | + | pub direct: Vec<i64>, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | /// `@username` tokens in a text, unique. |
|
| 24 | + | pub fn mentions(text: &str) -> Vec<String> { |
|
| 25 | + | let re = regex::Regex::new(r"(?:^|[^\w/])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})").unwrap(); |
|
| 26 | + | let mut out = BTreeSet::new(); |
|
| 27 | + | for c in re.captures_iter(text) { |
|
| 28 | + | out.insert(c[1].trim_end_matches('.').to_string()); |
|
| 29 | + | } |
|
| 30 | + | out.into_iter().collect() |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | /// Deliver an event: subscribers of the thread, watchers of the |
|
| 34 | + | /// repository, mentioned accounts and direct targets, never the actor. |
|
| 35 | + | pub async fn deliver(app: &Shared, ev: Event<'_>) { |
|
| 36 | + | let pool = &app.db; |
|
| 37 | + | let mut targets: BTreeSet<i64> = BTreeSet::new(); |
|
| 38 | + | if let Some((kind, id)) = ev.thread { |
|
| 39 | + | if let Ok(subs) = ndb::subscribers(pool, kind, id).await { |
|
| 40 | + | targets.extend(subs); |
|
| 41 | + | } |
|
| 42 | + | // Acting on a thread subscribes the actor to it. |
|
| 43 | + | ndb::subscribe(pool, ev.actor.id, kind, id).await.ok(); |
|
| 44 | + | } |
|
| 45 | + | if let Ok(w) = ndb::watchers(pool, ev.repo_id).await { |
|
| 46 | + | targets.extend(w); |
|
| 47 | + | } |
|
| 48 | + | for name in mentions(ev.mention_text) { |
|
| 49 | + | if let Ok(Some(a)) = db::accounts::account_by_name(pool, &name).await { |
|
| 50 | + | targets.insert(a.id); |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | targets.extend(ev.direct.iter().copied()); |
|
| 54 | + | targets.remove(&ev.actor.id); |
|
| 55 | + | for id in targets { |
|
| 56 | + | ndb::add(pool, id, ev.kind, &ev.title, &ev.url, &ev.actor.username).await.ok(); |
|
| 57 | + | if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await { |
|
| 58 | + | if a.email_notifications && !a.email.is_empty() { |
|
| 59 | + | email(app, &a, &ev.title, &ev.url, &ev.actor.username).await; |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | async fn email(app: &Shared, to: &Account, title: &str, url: &str, actor: &str) { |
|
| 66 | + | let cfg = &app.config.email; |
|
| 67 | + | if cfg.smtp.is_empty() || cfg.from.is_empty() { |
|
| 68 | + | return; |
|
| 69 | + | } |
|
| 70 | + | use lettre::message::header::ContentType; |
|
| 71 | + | use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; |
|
| 72 | + | let unsubscribe = format!("{}/settings/notifications?unsubscribe={}", app.config.base_url(), to.unsubscribe_key); |
|
| 73 | + | let body = format!("{actor}: {title}\n\n{}{url}\n\nStop these emails: {unsubscribe}\n", app.config.base_url()); |
|
| 74 | + | let Ok(message) = Message::builder() |
|
| 75 | + | .from(cfg.from.parse().unwrap_or_else(|_| "hub@localhost".parse().unwrap())) |
|
| 76 | + | .to(match format!("{} <{}>", to.name(), to.email).parse() { |
|
| 77 | + | Ok(m) => m, |
|
| 78 | + | Err(_) => return, |
|
| 79 | + | }) |
|
| 80 | + | .subject(format!("[{}] {title}", app.config.site.name)) |
|
| 81 | + | .header(ContentType::TEXT_PLAIN) |
|
| 82 | + | .body(body) |
|
| 83 | + | else { |
|
| 84 | + | return; |
|
| 85 | + | }; |
|
| 86 | + | let transport = match AsyncSmtpTransport::<Tokio1Executor>::from_url(&cfg.smtp) { |
|
| 87 | + | Ok(t) => t.build(), |
|
| 88 | + | Err(e) => { |
|
| 89 | + | tracing::warn!("smtp url: {e}"); |
|
| 90 | + | return; |
|
| 91 | + | } |
|
| 92 | + | }; |
|
| 93 | + | if let Err(e) = transport.send(message).await { |
|
| 94 | + | tracing::warn!("email to {}: {e}", to.email); |
|
| 95 | + | } |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | #[cfg(test)] |
|
| 99 | + | mod tests { |
|
| 100 | + | use super::*; |
|
| 101 | + | ||
| 102 | + | #[test] |
|
| 103 | + | fn finds_mentions() { |
|
| 104 | + | assert_eq!(mentions("cc @andy and @bob. not an email [email protected], nor /@path"), vec!["andy", "bob"]); |
|
| 105 | + | assert!(mentions("nothing here").is_empty()); |
|
| 106 | + | } |
|
| 107 | + | } |
added crates/hub/src/render/refs.rs +115 −0
| 1 | + | //! Cross references in Markdown (hub spec §8): `#12`, `owner/name#12`, |
|
| 2 | + | //! change hashes and `@user` become links before rendering. Text inside |
|
| 3 | + | //! code spans and fences is left alone. |
|
| 4 | + | ||
| 5 | + | /// Where a repository's numbers live: issues and proposals. |
|
| 6 | + | pub struct Numbers<'a> { |
|
| 7 | + | pub repo_url: &'a str, |
|
| 8 | + | pub issues: &'a [i64], |
|
| 9 | + | pub proposals: &'a [i64], |
|
| 10 | + | } |
|
| 11 | + | ||
| 12 | + | impl Numbers<'_> { |
|
| 13 | + | fn url_for(&self, n: i64) -> Option<String> { |
|
| 14 | + | if self.proposals.contains(&n) { |
|
| 15 | + | Some(format!("{}/proposal/{n}", self.repo_url)) |
|
| 16 | + | } else if self.issues.contains(&n) { |
|
| 17 | + | Some(format!("{}/issue/{n}", self.repo_url)) |
|
| 18 | + | } else { |
|
| 19 | + | None |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | /// Link references in `text`. Cross-repository references link to the |
|
| 25 | + | /// other repository's issue page, which resolves to a proposal itself. |
|
| 26 | + | pub fn link(text: &str, numbers: &Numbers<'_>) -> String { |
|
| 27 | + | let mut out = String::with_capacity(text.len() + 64); |
|
| 28 | + | let mut in_fence = false; |
|
| 29 | + | for line in text.split_inclusive('\n') { |
|
| 30 | + | if line.trim_start().starts_with("```") { |
|
| 31 | + | in_fence = !in_fence; |
|
| 32 | + | out.push_str(line); |
|
| 33 | + | continue; |
|
| 34 | + | } |
|
| 35 | + | if in_fence { |
|
| 36 | + | out.push_str(line); |
|
| 37 | + | continue; |
|
| 38 | + | } |
|
| 39 | + | // Split on inline code spans; only even segments are prose. |
|
| 40 | + | for (i, seg) in line.split('`').enumerate() { |
|
| 41 | + | if i > 0 { |
|
| 42 | + | out.push('`'); |
|
| 43 | + | } |
|
| 44 | + | if i % 2 == 0 { |
|
| 45 | + | out.push_str(&link_prose(seg, numbers)); |
|
| 46 | + | } else { |
|
| 47 | + | out.push_str(seg); |
|
| 48 | + | } |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | out |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | fn link_prose(seg: &str, numbers: &Numbers<'_>) -> String { |
|
| 55 | + | let re = regex::Regex::new(r"(?P<pre>^|[^\w/\[])(?:(?P<slug>[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*)#(?P<xn>\d+)|#(?P<n>\d+)|@(?P<user>[A-Za-z0-9][A-Za-z0-9._-]{0,63})|(?P<hash>\b[0-9a-f]{12,64}\b))").unwrap(); |
|
| 56 | + | re.replace_all(seg, |c: ®ex::Captures| { |
|
| 57 | + | let pre = &c["pre"]; |
|
| 58 | + | if let Some(slug) = c.name("slug") { |
|
| 59 | + | let n = &c["xn"]; |
|
| 60 | + | return format!("{pre}[{}#{n}](/{}/issue/{n})", slug.as_str(), slug.as_str()); |
|
| 61 | + | } |
|
| 62 | + | if let Some(n) = c.name("n") { |
|
| 63 | + | let num: i64 = n.as_str().parse().unwrap_or(0); |
|
| 64 | + | return match numbers.url_for(num) { |
|
| 65 | + | Some(url) => format!("{pre}[#{num}]({url})"), |
|
| 66 | + | None => c[0].to_string(), |
|
| 67 | + | }; |
|
| 68 | + | } |
|
| 69 | + | if let Some(u) = c.name("user") { |
|
| 70 | + | let name = u.as_str().trim_end_matches('.'); |
|
| 71 | + | return format!("{pre}[@{name}](/{name})"); |
|
| 72 | + | } |
|
| 73 | + | if let Some(h) = c.name("hash") { |
|
| 74 | + | let hex = h.as_str(); |
|
| 75 | + | return format!("{pre}[{}]({}/change/{hex})", &hex[..12.min(hex.len())], numbers.repo_url); |
|
| 76 | + | } |
|
| 77 | + | c[0].to_string() |
|
| 78 | + | }) |
|
| 79 | + | .into_owned() |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// Issue numbers a change message closes: `closes #12`, `fixes #3`, |
|
| 83 | + | /// `resolved #7`. |
|
| 84 | + | pub fn closes(message: &str) -> Vec<i64> { |
|
| 85 | + | let re = regex::Regex::new(r"(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)").unwrap(); |
|
| 86 | + | let mut out: Vec<i64> = re.captures_iter(message).filter_map(|c| c[1].parse().ok()).collect(); |
|
| 87 | + | out.sort(); |
|
| 88 | + | out.dedup(); |
|
| 89 | + | out |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | #[cfg(test)] |
|
| 93 | + | mod tests { |
|
| 94 | + | use super::*; |
|
| 95 | + | ||
| 96 | + | #[test] |
|
| 97 | + | fn links_references_outside_code() { |
|
| 98 | + | let n = Numbers { repo_url: "/a/b", issues: &[3], proposals: &[7] }; |
|
| 99 | + | let out = link("see #3 and #7 and #9, `#3` too, by @andy; a/b#3 and other/repo#5; hash deadbeefcafe1234\n```\n#3 @x\n```\n", &n); |
|
| 100 | + | assert!(out.contains("[#3](/a/b/issue/3)"), "{out}"); |
|
| 101 | + | assert!(out.contains("[#7](/a/b/proposal/7)"), "{out}"); |
|
| 102 | + | assert!(out.contains(" #9,"), "unknown numbers stay: {out}"); |
|
| 103 | + | assert!(out.contains("`#3`"), "code spans untouched: {out}"); |
|
| 104 | + | assert!(out.contains("[@andy](/andy)"), "{out}"); |
|
| 105 | + | assert!(out.contains("[other/repo#5](/other/repo/issue/5)"), "{out}"); |
|
| 106 | + | assert!(out.contains("[deadbeefcafe](/a/b/change/deadbeefcafe1234)"), "{out}"); |
|
| 107 | + | assert!(out.contains("```\n#3 @x\n```"), "fences untouched: {out}"); |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | #[test] |
|
| 111 | + | fn closing_keywords() { |
|
| 112 | + | assert_eq!(closes("Fixes #12 and closes #3, resolved #12"), vec![3, 12]); |
|
| 113 | + | assert!(closes("mentions #4 only").is_empty()); |
|
| 114 | + | } |
|
| 115 | + | } |
added crates/hub/src/web/inbox.rs +170 −0
| 1 | + | //! The inbox, notification preferences and search (hub spec §10, §11). |
|
| 2 | + | ||
| 3 | + | use super::settings::{csrf, require}; |
|
| 4 | + | use super::{filters, Error, Page, Shell}; |
|
| 5 | + | use crate::auth::Viewer; |
|
| 6 | + | use crate::db::{self, accounts, notify as ndb}; |
|
| 7 | + | use crate::Shared; |
|
| 8 | + | use askama::Template; |
|
| 9 | + | use axum::extract::{Form, Query, State}; |
|
| 10 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 11 | + | use hub_api::Visibility; |
|
| 12 | + | use serde::Deserialize; |
|
| 13 | + | ||
| 14 | + | #[derive(Template)] |
|
| 15 | + | #[template(path = "notifications.html")] |
|
| 16 | + | pub struct InboxPage { |
|
| 17 | + | shell: Shell, |
|
| 18 | + | rows: Vec<ndb::Notification>, |
|
| 19 | + | unread: i64, |
|
| 20 | + | email_on: bool, |
|
| 21 | + | notice: Option<String>, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | async fn inbox_page(app: &Shared, shell: Shell, me: &accounts::Account, notice: Option<String>) -> Result<Page<InboxPage>, Error> { |
|
| 25 | + | let rows = ndb::inbox(&app.db, me.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 26 | + | let unread = ndb::unread(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 27 | + | let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten(); |
|
| 28 | + | Ok(Page(InboxPage { shell, rows, unread, email_on: fresh.map(|a| a.email_notifications).unwrap_or(false), notice })) |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | pub async fn inbox(State(app): State<Shared>, viewer: Viewer) -> Result<Page<InboxPage>, Error> { |
|
| 32 | + | let shell = Shell::new(&app, "/notifications", &viewer); |
|
| 33 | + | let me = require(&viewer, &shell)?; |
|
| 34 | + | inbox_page(&app, shell, &me.account, None).await |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | #[derive(Deserialize)] |
|
| 38 | + | pub struct ReadForm { |
|
| 39 | + | #[serde(rename = "_csrf")] |
|
| 40 | + | csrf_token: String, |
|
| 41 | + | #[serde(default)] |
|
| 42 | + | id: Option<i64>, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | pub async fn mark_read(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ReadForm>) -> Result<Page<InboxPage>, Error> { |
|
| 46 | + | let shell = Shell::new(&app, "/notifications", &viewer); |
|
| 47 | + | let me = require(&viewer, &shell)?; |
|
| 48 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 49 | + | ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 50 | + | inbox_page(&app, shell, &me.account, None).await |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | #[derive(Deserialize)] |
|
| 54 | + | pub struct PrefsForm { |
|
| 55 | + | #[serde(rename = "_csrf")] |
|
| 56 | + | csrf_token: String, |
|
| 57 | + | #[serde(default)] |
|
| 58 | + | email: Option<String>, |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | pub async fn prefs(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PrefsForm>) -> Result<Page<InboxPage>, Error> { |
|
| 62 | + | let shell = Shell::new(&app, "/notifications", &viewer); |
|
| 63 | + | let me = require(&viewer, &shell)?; |
|
| 64 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 65 | + | let key = if me.account.unsubscribe_key.is_empty() { crate::auth::random_hex(16) } else { me.account.unsubscribe_key.clone() }; |
|
| 66 | + | sqlx::query("UPDATE accounts SET email_notifications = ?, unsubscribe_key = ? WHERE id = ?") |
|
| 67 | + | .bind(f.email.is_some()) |
|
| 68 | + | .bind(&key) |
|
| 69 | + | .bind(me.account.id) |
|
| 70 | + | .execute(&app.db) |
|
| 71 | + | .await |
|
| 72 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 73 | + | inbox_page( |
|
| 74 | + | &app, |
|
| 75 | + | shell, |
|
| 76 | + | &me.account, |
|
| 77 | + | Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }), |
|
| 78 | + | ) |
|
| 79 | + | .await |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | #[derive(Deserialize)] |
|
| 83 | + | pub struct UnsubQuery { |
|
| 84 | + | unsubscribe: Option<String>, |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | /// The one-click link in every email: no session needed, the key is the proof. |
|
| 88 | + | pub async fn unsubscribe(State(app): State<Shared>, Query(q): Query<UnsubQuery>) -> Response { |
|
| 89 | + | if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) { |
|
| 90 | + | sqlx::query("UPDATE accounts SET email_notifications = 0 WHERE unsubscribe_key = ?").bind(key).execute(&app.db).await.ok(); |
|
| 91 | + | return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response(); |
|
| 92 | + | } |
|
| 93 | + | Redirect::to("/notifications").into_response() |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | // ------------------------------------------------------------------ search |
|
| 97 | + | ||
| 98 | + | pub struct HitView { |
|
| 99 | + | pub kind: String, |
|
| 100 | + | pub slug: String, |
|
| 101 | + | pub number: i64, |
|
| 102 | + | pub title: String, |
|
| 103 | + | pub snippet: String, |
|
| 104 | + | pub url: String, |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | #[derive(Template)] |
|
| 108 | + | #[template(path = "search.html")] |
|
| 109 | + | pub struct SearchPage { |
|
| 110 | + | shell: Shell, |
|
| 111 | + | q: String, |
|
| 112 | + | repos: Vec<db::repos::RepoRow>, |
|
| 113 | + | accounts: Vec<String>, |
|
| 114 | + | hits: Vec<HitView>, |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | #[derive(Deserialize)] |
|
| 118 | + | pub struct SearchQuery { |
|
| 119 | + | q: Option<String>, |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | pub async fn search(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SearchQuery>) -> Result<Page<SearchPage>, Error> { |
|
| 123 | + | let shell = Shell::new(&app, "/search", &viewer); |
|
| 124 | + | let query = q.q.unwrap_or_default().trim().to_string(); |
|
| 125 | + | if query.is_empty() { |
|
| 126 | + | return Ok(Page(SearchPage { shell, q: query, repos: vec![], accounts: vec![], hits: vec![] })); |
|
| 127 | + | } |
|
| 128 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 129 | + | // What the viewer may see. |
|
| 130 | + | let all = db::repos::list(&app.db).await.map_err(e)?; |
|
| 131 | + | let mut visible = Vec::new(); |
|
| 132 | + | for r in all { |
|
| 133 | + | let role = crate::auth::roles::role_for(&app.db, viewer.account(), &r).await.map_err(e)?; |
|
| 134 | + | if crate::auth::roles::allows(role, crate::auth::Access::Read) { |
|
| 135 | + | visible.push(r); |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | let lower = query.to_lowercase(); |
|
| 139 | + | let repos: Vec<db::repos::RepoRow> = visible |
|
| 140 | + | .iter() |
|
| 141 | + | .filter(|r| { |
|
| 142 | + | r.name.to_lowercase().contains(&lower) |
|
| 143 | + | || r.description.to_lowercase().contains(&lower) |
|
| 144 | + | || r.owner.to_lowercase().contains(&lower) |
|
| 145 | + | }) |
|
| 146 | + | .cloned() |
|
| 147 | + | .collect(); |
|
| 148 | + | let accounts: Vec<String> = accounts::accounts(&app.db) |
|
| 149 | + | .await |
|
| 150 | + | .map_err(e)? |
|
| 151 | + | .into_iter() |
|
| 152 | + | .filter(|a| a.username.to_lowercase().contains(&lower) || a.display_name.to_lowercase().contains(&lower)) |
|
| 153 | + | .map(|a| a.username) |
|
| 154 | + | .collect(); |
|
| 155 | + | let mut hits = Vec::new(); |
|
| 156 | + | for h in ndb::search(&app.db, &query, None, 100).await.map_err(e)? { |
|
| 157 | + | let Some(r) = visible.iter().find(|r| r.id == h.repo_id) else { continue }; |
|
| 158 | + | let url = format!("/{}/{}/{}", r.slug(), if h.kind == "proposal" { "proposal" } else { "issue" }, h.number); |
|
| 159 | + | hits.push(HitView { |
|
| 160 | + | kind: h.kind, |
|
| 161 | + | slug: r.slug(), |
|
| 162 | + | number: h.number, |
|
| 163 | + | title: h.title, |
|
| 164 | + | snippet: crate::render::escape(&h.snippet).replace("<mark>", "<mark>").replace("</mark>", "</mark>"), |
|
| 165 | + | url, |
|
| 166 | + | }); |
|
| 167 | + | } |
|
| 168 | + | let _ = Visibility::Public; |
|
| 169 | + | Ok(Page(SearchPage { shell, q: query, repos, accounts, hits })) |
|
| 170 | + | } |
added crates/hub/src/web/issues.rs +896 −0
| 1 | + | //! Issues, labels and milestones (hub spec §8). |
|
| 2 | + | ||
| 3 | + | use super::settings::{csrf, require}; |
|
| 4 | + | use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; |
|
| 5 | + | use crate::auth::{Access, Role, Viewer}; |
|
| 6 | + | use crate::db::{self, accounts, issues as idb, notify as ndb}; |
|
| 7 | + | use crate::notify::{self, Event}; |
|
| 8 | + | use crate::render::{self, refs::Numbers}; |
|
| 9 | + | use crate::repos::view; |
|
| 10 | + | use crate::Shared; |
|
| 11 | + | use askama::Template; |
|
| 12 | + | use axum::extract::{Form, Path, Query, State}; |
|
| 13 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 14 | + | use serde::Deserialize; |
|
| 15 | + | use std::collections::HashMap; |
|
| 16 | + | ||
| 17 | + | /// Markdown with cross references linked, for a repository. |
|
| 18 | + | pub async fn markdown(app: &Shared, repo_id: i64, repo_url: &str, text: &str) -> String { |
|
| 19 | + | let (issues, proposals) = idb::numbers(&app.db, repo_id).await.unwrap_or_default(); |
|
| 20 | + | let linked = render::refs::link(text, &Numbers { repo_url, issues: &issues, proposals: &proposals }); |
|
| 21 | + | render::markdown::render(&linked, &format!("{repo_url}/file/main/"), &format!("{repo_url}/file/main/")) |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | /// Re-index a thread for search: title, body and every comment. |
|
| 25 | + | pub async fn reindex(app: &Shared, repo_id: i64, issue: &idb::IssueRow) { |
|
| 26 | + | let comments = idb::comments(&app.db, issue.id).await.unwrap_or_default(); |
|
| 27 | + | let text = std::iter::once(issue.body.clone()).chain(comments.into_iter().map(|c| c.body)).collect::<Vec<_>>().join("\n\n"); |
|
| 28 | + | ndb::index(&app.db, "issue", repo_id, issue.id, issue.number, &issue.title, &text).await.ok(); |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | // ------------------------------------------------------------------ list |
|
| 32 | + | ||
| 33 | + | pub struct IssueLine { |
|
| 34 | + | pub issue: idb::IssueRow, |
|
| 35 | + | pub labels: Vec<idb::Label>, |
|
| 36 | + | pub assignees: Vec<String>, |
|
| 37 | + | pub milestone: Option<String>, |
|
| 38 | + | pub comments: i64, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | #[derive(Template)] |
|
| 42 | + | #[template(path = "issues.html")] |
|
| 43 | + | pub struct IssuesPage { |
|
| 44 | + | shell: Shell, |
|
| 45 | + | repo: RepoShell, |
|
| 46 | + | rows: Vec<IssueLine>, |
|
| 47 | + | state: String, |
|
| 48 | + | q: String, |
|
| 49 | + | counts: (usize, usize), |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | #[derive(Deserialize)] |
|
| 53 | + | pub struct ListQuery { |
|
| 54 | + | state: Option<String>, |
|
| 55 | + | q: Option<String>, |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// `label:bug assignee:andy milestone:v1 state:closed words…` |
|
| 59 | + | struct Filter { |
|
| 60 | + | state: String, |
|
| 61 | + | labels: Vec<String>, |
|
| 62 | + | assignees: Vec<String>, |
|
| 63 | + | milestone: Option<String>, |
|
| 64 | + | words: Vec<String>, |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | fn parse_filter(state: Option<String>, q: &str) -> Filter { |
|
| 68 | + | let mut f = Filter { state: state.unwrap_or_else(|| "open".into()), labels: vec![], assignees: vec![], milestone: None, words: vec![] }; |
|
| 69 | + | for tok in q.split_whitespace() { |
|
| 70 | + | match tok.split_once(':') { |
|
| 71 | + | Some(("label", v)) => f.labels.push(v.to_lowercase()), |
|
| 72 | + | Some(("assignee", v)) => f.assignees.push(v.to_lowercase()), |
|
| 73 | + | Some(("milestone", v)) => f.milestone = Some(v.to_lowercase()), |
|
| 74 | + | Some(("state", v)) => f.state = v.to_string(), |
|
| 75 | + | _ => f.words.push(tok.to_lowercase()), |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | f |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub async fn list( |
|
| 82 | + | State(app): State<Shared>, |
|
| 83 | + | viewer: Viewer, |
|
| 84 | + | Path((owner, name)): Path<(String, String)>, |
|
| 85 | + | Query(q): Query<ListQuery>, |
|
| 86 | + | ) -> Result<Page<IssuesPage>, Error> { |
|
| 87 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issues"), &viewer); |
|
| 88 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 89 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 90 | + | let all = idb::list(&app.db, row.id).await.map_err(e)?; |
|
| 91 | + | let counts = (all.iter().filter(|i| i.state == "open").count(), all.iter().filter(|i| i.state == "closed").count()); |
|
| 92 | + | let qtext = q.q.clone().unwrap_or_default(); |
|
| 93 | + | let f = parse_filter(q.state, &qtext); |
|
| 94 | + | let mut labels_by: HashMap<i64, Vec<idb::Label>> = HashMap::new(); |
|
| 95 | + | for (id, l) in idb::labels_by_issue(&app.db, row.id).await.map_err(e)? { |
|
| 96 | + | labels_by.entry(id).or_default().push(l); |
|
| 97 | + | } |
|
| 98 | + | let mut assignees_by: HashMap<i64, Vec<String>> = HashMap::new(); |
|
| 99 | + | for (id, u) in idb::assignees_by_issue(&app.db, row.id).await.map_err(e)? { |
|
| 100 | + | assignees_by.entry(id).or_default().push(u); |
|
| 101 | + | } |
|
| 102 | + | let milestones: HashMap<i64, String> = |
|
| 103 | + | idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().map(|m| (m.id, m.title)).collect(); |
|
| 104 | + | let mut rows = Vec::new(); |
|
| 105 | + | for i in all { |
|
| 106 | + | let labels = labels_by.remove(&i.id).unwrap_or_default(); |
|
| 107 | + | let assignees = assignees_by.remove(&i.id).unwrap_or_default(); |
|
| 108 | + | let milestone = i.milestone_id.and_then(|m| milestones.get(&m).cloned()); |
|
| 109 | + | let keep = (f.state == "all" || i.state == f.state) |
|
| 110 | + | && f.labels.iter().all(|l| labels.iter().any(|x| x.name.to_lowercase() == *l)) |
|
| 111 | + | && f.assignees.iter().all(|a| assignees.iter().any(|x| x.to_lowercase() == *a)) |
|
| 112 | + | && f.milestone.as_ref().is_none_or(|m| milestone.as_ref().is_some_and(|x| x.to_lowercase() == *m)) |
|
| 113 | + | && f.words.iter().all(|w| i.title.to_lowercase().contains(w) || i.body.to_lowercase().contains(w)); |
|
| 114 | + | if keep { |
|
| 115 | + | let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?") |
|
| 116 | + | .bind(i.id) |
|
| 117 | + | .fetch_one(&app.db) |
|
| 118 | + | .await |
|
| 119 | + | .map_err(e)?; |
|
| 120 | + | rows.push(IssueLine { issue: i, labels, assignees, milestone, comments }); |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | Ok(Page(IssuesPage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), rows, state: f.state, q: qtext, counts })) |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | // ------------------------------------------------------------------- new |
|
| 127 | + | ||
| 128 | + | #[derive(Template)] |
|
| 129 | + | #[template(path = "issue_new.html")] |
|
| 130 | + | pub struct NewIssuePage { |
|
| 131 | + | shell: Shell, |
|
| 132 | + | repo: RepoShell, |
|
| 133 | + | templates: Vec<(String, String)>, |
|
| 134 | + | template: String, |
|
| 135 | + | error: Option<String>, |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | #[derive(Deserialize)] |
|
| 139 | + | pub struct NewQuery { |
|
| 140 | + | template: Option<String>, |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | /// `.oxyhub/ISSUE_TEMPLATE/*.md` in the default bookmark's state. |
|
| 144 | + | async fn issue_templates(app: &Shared, repo: &crate::repos::RepoName, default: &str) -> Vec<(String, String)> { |
|
| 145 | + | let default = default.to_string(); |
|
| 146 | + | app.repos |
|
| 147 | + | .read(repo, move |r, cache| { |
|
| 148 | + | let Ok(target) = view::resolve(r, cache, &default) else { return Ok(vec![]) }; |
|
| 149 | + | let loaded = cache.load(r, &target.heads)?; |
|
| 150 | + | let mut out = Vec::new(); |
|
| 151 | + | for (path, f) in &loaded.snapshot.files { |
|
| 152 | + | if let Some(rest) = path.strip_prefix(".oxyhub/ISSUE_TEMPLATE/") { |
|
| 153 | + | if let (Some(name), oxy_core::state::FileContent::Text { bytes, .. }) = (rest.strip_suffix(".md"), &f.content) { |
|
| 154 | + | out.push((name.to_string(), String::from_utf8_lossy(bytes).into_owned())); |
|
| 155 | + | } |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 158 | + | Ok(out) |
|
| 159 | + | }) |
|
| 160 | + | .await |
|
| 161 | + | .unwrap_or_default() |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | pub async fn new_form( |
|
| 165 | + | State(app): State<Shared>, |
|
| 166 | + | viewer: Viewer, |
|
| 167 | + | Path((owner, name)): Path<(String, String)>, |
|
| 168 | + | Query(q): Query<NewQuery>, |
|
| 169 | + | ) -> Result<Page<NewIssuePage>, Error> { |
|
| 170 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issues/new"), &viewer); |
|
| 171 | + | require(&viewer, &shell)?; |
|
| 172 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 173 | + | let templates = issue_templates(&app, &repo, &row.default_bookmark).await; |
|
| 174 | + | let template = match q.template.as_deref() { |
|
| 175 | + | Some("blank") => " ".into(), |
|
| 176 | + | Some(t) => templates.iter().find(|(n, _)| n == t).map(|(_, b)| b.clone()).unwrap_or_default(), |
|
| 177 | + | None if templates.is_empty() => " ".into(), |
|
| 178 | + | None => String::new(), |
|
| 179 | + | }; |
|
| 180 | + | Ok(Page(NewIssuePage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), templates, template, error: None })) |
|
| 181 | + | } |
|
| 182 | + | ||
| 183 | + | #[derive(Deserialize)] |
|
| 184 | + | pub struct NewIssueForm { |
|
| 185 | + | #[serde(rename = "_csrf")] |
|
| 186 | + | csrf_token: String, |
|
| 187 | + | title: String, |
|
| 188 | + | #[serde(default)] |
|
| 189 | + | body: String, |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | pub async fn create( |
|
| 193 | + | State(app): State<Shared>, |
|
| 194 | + | viewer: Viewer, |
|
| 195 | + | Path((owner, name)): Path<(String, String)>, |
|
| 196 | + | Form(f): Form<NewIssueForm>, |
|
| 197 | + | ) -> Result<Response, Error> { |
|
| 198 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issues/new"), &viewer); |
|
| 199 | + | let me = require(&viewer, &shell)?; |
|
| 200 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 201 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 202 | + | if f.title.trim().is_empty() { |
|
| 203 | + | return Ok(Page(NewIssuePage { |
|
| 204 | + | shell, |
|
| 205 | + | repo: RepoShell::new(&app, &row, "issues").with_role(role), |
|
| 206 | + | templates: vec![], |
|
| 207 | + | template: f.body, |
|
| 208 | + | error: Some("Give the issue a title.".into()), |
|
| 209 | + | }) |
|
| 210 | + | .into_response()); |
|
| 211 | + | } |
|
| 212 | + | let issue = idb::create( |
|
| 213 | + | &app.db, |
|
| 214 | + | idb::NewIssue { |
|
| 215 | + | repo_id: row.id, |
|
| 216 | + | title: f.title.trim(), |
|
| 217 | + | body: f.body.trim(), |
|
| 218 | + | author_id: Some(me.account.id), |
|
| 219 | + | author: &me.account.username, |
|
| 220 | + | }, |
|
| 221 | + | ) |
|
| 222 | + | .await |
|
| 223 | + | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 224 | + | idb::add_event(&app.db, issue.id, &me.account.username, "opened", "").await.ok(); |
|
| 225 | + | reindex(&app, row.id, &issue).await; |
|
| 226 | + | let url = format!("/{owner}/{name}/issue/{}", issue.number); |
|
| 227 | + | notify::deliver( |
|
| 228 | + | &app, |
|
| 229 | + | Event { |
|
| 230 | + | kind: "issue.opened", |
|
| 231 | + | title: format!("{owner}/{name}#{}: {}", issue.number, issue.title), |
|
| 232 | + | url: url.clone(), |
|
| 233 | + | actor: &me.account, |
|
| 234 | + | repo_id: row.id, |
|
| 235 | + | thread: Some(("issue", issue.id)), |
|
| 236 | + | mention_text: &f.body, |
|
| 237 | + | direct: vec![], |
|
| 238 | + | }, |
|
| 239 | + | ) |
|
| 240 | + | .await; |
|
| 241 | + | Ok(Redirect::to(&url).into_response()) |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | // ---------------------------------------------------------------- detail |
|
| 245 | + | ||
| 246 | + | pub enum Item { |
|
| 247 | + | Comment(idb::IssueComment, String), |
|
| 248 | + | Event(idb::IssueEvent), |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | #[derive(Template)] |
|
| 252 | + | #[template(path = "issue.html")] |
|
| 253 | + | pub struct IssuePage { |
|
| 254 | + | shell: Shell, |
|
| 255 | + | repo: RepoShell, |
|
| 256 | + | i: idb::IssueRow, |
|
| 257 | + | body_html: String, |
|
| 258 | + | timeline: Vec<Item>, |
|
| 259 | + | comments: Vec<idb::IssueComment>, |
|
| 260 | + | labels: Vec<idb::Label>, |
|
| 261 | + | all_labels: Vec<idb::Label>, |
|
| 262 | + | assignees: Vec<String>, |
|
| 263 | + | milestone: Option<idb::Milestone>, |
|
| 264 | + | milestones: Vec<(idb::Milestone, bool)>, |
|
| 265 | + | subscribed: bool, |
|
| 266 | + | can_edit: bool, |
|
| 267 | + | can_triage: bool, |
|
| 268 | + | notice: Option<String>, |
|
| 269 | + | error: Option<String>, |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | /// The page assembler every action ends in. |
|
| 273 | + | #[allow(clippy::too_many_arguments)] |
|
| 274 | + | async fn detail( |
|
| 275 | + | app: &Shared, |
|
| 276 | + | shell: Shell, |
|
| 277 | + | viewer: &Viewer, |
|
| 278 | + | owner: &str, |
|
| 279 | + | name: &str, |
|
| 280 | + | number: i64, |
|
| 281 | + | notice: Option<String>, |
|
| 282 | + | error: Option<String>, |
|
| 283 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 284 | + | let (_, row, role) = lookup(app, &shell, viewer, owner, name).await?; |
|
| 285 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 286 | + | let i = idb::get(&app.db, row.id, number).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 287 | + | let repo_url = format!("/{owner}/{name}"); |
|
| 288 | + | let body_html = markdown(app, row.id, &repo_url, &i.body).await; |
|
| 289 | + | let comments = idb::comments(&app.db, i.id).await.map_err(e)?; |
|
| 290 | + | let events = idb::events(&app.db, i.id).await.map_err(e)?; |
|
| 291 | + | let mut timeline: Vec<(i64, Item)> = Vec::new(); |
|
| 292 | + | for c in &comments { |
|
| 293 | + | timeline.push((c.created_ms, Item::Comment(c.clone(), markdown(app, row.id, &repo_url, &c.body).await))); |
|
| 294 | + | } |
|
| 295 | + | for ev in events { |
|
| 296 | + | if ev.event != "opened" { |
|
| 297 | + | timeline.push((ev.at_ms, Item::Event(ev))); |
|
| 298 | + | } |
|
| 299 | + | } |
|
| 300 | + | timeline.sort_by_key(|(t, _)| *t); |
|
| 301 | + | let labels = idb::issue_labels(&app.db, i.id).await.map_err(e)?; |
|
| 302 | + | let all_labels = idb::labels(&app.db, row.id).await.map_err(e)?; |
|
| 303 | + | let assignees = idb::assignees(&app.db, i.id).await.map_err(e)?; |
|
| 304 | + | let milestones: Vec<(idb::Milestone, bool)> = idb::milestones(&app.db, row.id) |
|
| 305 | + | .await |
|
| 306 | + | .map_err(e)? |
|
| 307 | + | .into_iter() |
|
| 308 | + | .map(|m| { |
|
| 309 | + | let sel = i.milestone_id == Some(m.id); |
|
| 310 | + | (m, sel) |
|
| 311 | + | }) |
|
| 312 | + | .collect(); |
|
| 313 | + | let milestone = match i.milestone_id { |
|
| 314 | + | Some(id) => idb::milestone(&app.db, id).await.map_err(e)?, |
|
| 315 | + | None => None, |
|
| 316 | + | }; |
|
| 317 | + | let me = viewer.account(); |
|
| 318 | + | let subscribed = match me { |
|
| 319 | + | Some(a) => ndb::subscribed(&app.db, a.id, "issue", i.id).await.map_err(e)?, |
|
| 320 | + | None => false, |
|
| 321 | + | }; |
|
| 322 | + | let can_triage = role >= Role::Triage; |
|
| 323 | + | let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage; |
|
| 324 | + | Ok(Page(IssuePage { |
|
| 325 | + | shell, |
|
| 326 | + | repo: RepoShell::new(app, &row, "issues").with_role(role), |
|
| 327 | + | i, |
|
| 328 | + | body_html, |
|
| 329 | + | timeline: timeline.into_iter().map(|(_, it)| it).collect(), |
|
| 330 | + | comments, |
|
| 331 | + | labels, |
|
| 332 | + | all_labels, |
|
| 333 | + | assignees, |
|
| 334 | + | milestone, |
|
| 335 | + | milestones, |
|
| 336 | + | subscribed, |
|
| 337 | + | can_edit, |
|
| 338 | + | can_triage, |
|
| 339 | + | notice, |
|
| 340 | + | error, |
|
| 341 | + | })) |
|
| 342 | + | } |
|
| 343 | + | ||
| 344 | + | pub async fn show( |
|
| 345 | + | State(app): State<Shared>, |
|
| 346 | + | viewer: Viewer, |
|
| 347 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 348 | + | ) -> Result<Response, Error> { |
|
| 349 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 350 | + | // A number that belongs to a proposal goes there (shared number space). |
|
| 351 | + | let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 352 | + | if idb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_none() |
|
| 353 | + | && db::proposals::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some() |
|
| 354 | + | { |
|
| 355 | + | return Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{number}")).into_response()); |
|
| 356 | + | } |
|
| 357 | + | Ok(detail(&app, shell, &viewer, &owner, &name, number, None, None).await?.into_response()) |
|
| 358 | + | } |
|
| 359 | + | ||
| 360 | + | // ---------------------------------------------------------------- actions |
|
| 361 | + | ||
| 362 | + | async fn issue_for( |
|
| 363 | + | app: &Shared, |
|
| 364 | + | shell: &Shell, |
|
| 365 | + | viewer: &Viewer, |
|
| 366 | + | owner: &str, |
|
| 367 | + | name: &str, |
|
| 368 | + | number: i64, |
|
| 369 | + | access: Access, |
|
| 370 | + | ) -> Result<(db::repos::RepoRow, idb::IssueRow, Role), Error> { |
|
| 371 | + | let (_, row, role) = lookup_role(app, shell, viewer, owner, name, access).await?; |
|
| 372 | + | let i = idb::get(&app.db, row.id, number) |
|
| 373 | + | .await |
|
| 374 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 375 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 376 | + | Ok((row, i, role)) |
|
| 377 | + | } |
|
| 378 | + | ||
| 379 | + | #[derive(Deserialize)] |
|
| 380 | + | pub struct CommentForm { |
|
| 381 | + | #[serde(rename = "_csrf")] |
|
| 382 | + | csrf_token: String, |
|
| 383 | + | body: String, |
|
| 384 | + | #[serde(default)] |
|
| 385 | + | close: Option<String>, |
|
| 386 | + | #[serde(default)] |
|
| 387 | + | reopen: Option<String>, |
|
| 388 | + | } |
|
| 389 | + | ||
| 390 | + | pub async fn comment( |
|
| 391 | + | State(app): State<Shared>, |
|
| 392 | + | viewer: Viewer, |
|
| 393 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 394 | + | Form(f): Form<CommentForm>, |
|
| 395 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 396 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 397 | + | let me = require(&viewer, &shell)?; |
|
| 398 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 399 | + | let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; |
|
| 400 | + | if i.locked && role < Role::Triage { |
|
| 401 | + | return detail(&app, shell, &viewer, &owner, &name, number, None, Some("This issue is locked.".into())).await; |
|
| 402 | + | } |
|
| 403 | + | if f.body.trim().is_empty() { |
|
| 404 | + | return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An empty comment says nothing.".into())).await; |
|
| 405 | + | } |
|
| 406 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 407 | + | idb::add_comment(&app.db, i.id, Some(me.account.id), &me.account.username, f.body.trim()).await.map_err(e)?; |
|
| 408 | + | let url = format!("/{owner}/{name}/issue/{number}"); |
|
| 409 | + | let mut kind = "issue.comment"; |
|
| 410 | + | let may_close = Some(me.account.id) == i.author_id || role >= Role::Triage; |
|
| 411 | + | if f.close.is_some() && i.state == "open" && may_close { |
|
| 412 | + | idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?; |
|
| 413 | + | idb::add_event(&app.db, i.id, &me.account.username, "closed", "").await.ok(); |
|
| 414 | + | kind = "issue.closed"; |
|
| 415 | + | } else if f.reopen.is_some() && i.state == "closed" && may_close { |
|
| 416 | + | idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?; |
|
| 417 | + | idb::add_event(&app.db, i.id, &me.account.username, "reopened", "").await.ok(); |
|
| 418 | + | kind = "issue.reopened"; |
|
| 419 | + | } |
|
| 420 | + | reindex(&app, row.id, &i).await; |
|
| 421 | + | let direct = i.author_id.into_iter().collect(); |
|
| 422 | + | notify::deliver( |
|
| 423 | + | &app, |
|
| 424 | + | Event { |
|
| 425 | + | kind, |
|
| 426 | + | title: format!("{owner}/{name}#{number}: {}", i.title), |
|
| 427 | + | url, |
|
| 428 | + | actor: &me.account, |
|
| 429 | + | repo_id: row.id, |
|
| 430 | + | thread: Some(("issue", i.id)), |
|
| 431 | + | mention_text: &f.body, |
|
| 432 | + | direct, |
|
| 433 | + | }, |
|
| 434 | + | ) |
|
| 435 | + | .await; |
|
| 436 | + | detail(&app, shell, &viewer, &owner, &name, number, None, None).await |
|
| 437 | + | } |
|
| 438 | + | ||
| 439 | + | pub async fn delete_comment( |
|
| 440 | + | State(app): State<Shared>, |
|
| 441 | + | viewer: Viewer, |
|
| 442 | + | Path((owner, name, number, id)): Path<(String, String, i64, i64)>, |
|
| 443 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 444 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 445 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 446 | + | let me = require(&viewer, &shell)?; |
|
| 447 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 448 | + | let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; |
|
| 449 | + | let c = idb::comments(&app.db, i.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().find(|c| c.id == id); |
|
| 450 | + | if let Some(c) = c { |
|
| 451 | + | if c.author_id == Some(me.account.id) || role >= Role::Triage { |
|
| 452 | + | idb::delete_comment(&app.db, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 453 | + | idb::add_event(&app.db, i.id, &me.account.username, "deleted a comment", "").await.ok(); |
|
| 454 | + | reindex(&app, row.id, &i).await; |
|
| 455 | + | } |
|
| 456 | + | } |
|
| 457 | + | detail(&app, shell, &viewer, &owner, &name, number, None, None).await |
|
| 458 | + | } |
|
| 459 | + | ||
| 460 | + | #[derive(Deserialize)] |
|
| 461 | + | pub struct EditForm { |
|
| 462 | + | #[serde(rename = "_csrf")] |
|
| 463 | + | csrf_token: String, |
|
| 464 | + | title: String, |
|
| 465 | + | #[serde(default)] |
|
| 466 | + | body: String, |
|
| 467 | + | } |
|
| 468 | + | ||
| 469 | + | pub async fn edit( |
|
| 470 | + | State(app): State<Shared>, |
|
| 471 | + | viewer: Viewer, |
|
| 472 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 473 | + | Form(f): Form<EditForm>, |
|
| 474 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 475 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 476 | + | let me = require(&viewer, &shell)?; |
|
| 477 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 478 | + | let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; |
|
| 479 | + | if Some(me.account.id) != i.author_id && role < Role::Triage { |
|
| 480 | + | return Err(Error::not_found(shell)); |
|
| 481 | + | } |
|
| 482 | + | if f.title.trim().is_empty() { |
|
| 483 | + | return detail(&app, shell, &viewer, &owner, &name, number, None, Some("An issue keeps a title.".into())).await; |
|
| 484 | + | } |
|
| 485 | + | idb::edit(&app.db, i.id, f.title.trim(), f.body.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 486 | + | idb::add_event(&app.db, i.id, &me.account.username, "edited", "").await.ok(); |
|
| 487 | + | if let Ok(Some(fresh)) = idb::get(&app.db, row.id, number).await { |
|
| 488 | + | reindex(&app, row.id, &fresh).await; |
|
| 489 | + | } |
|
| 490 | + | detail(&app, shell, &viewer, &owner, &name, number, Some("Saved.".into()), None).await |
|
| 491 | + | } |
|
| 492 | + | ||
| 493 | + | #[derive(Deserialize)] |
|
| 494 | + | pub struct StateForm { |
|
| 495 | + | #[serde(rename = "_csrf")] |
|
| 496 | + | csrf_token: String, |
|
| 497 | + | action: String, |
|
| 498 | + | } |
|
| 499 | + | ||
| 500 | + | pub async fn state( |
|
| 501 | + | State(app): State<Shared>, |
|
| 502 | + | viewer: Viewer, |
|
| 503 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 504 | + | Form(f): Form<StateForm>, |
|
| 505 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 506 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 507 | + | let me = require(&viewer, &shell)?; |
|
| 508 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 509 | + | let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; |
|
| 510 | + | let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 511 | + | if !crate::auth::roles::allows(role, Access::Read) || (role < Some(Role::Triage) && Some(me.account.id) != i.author_id) { |
|
| 512 | + | return Err(Error::not_found(shell)); |
|
| 513 | + | } |
|
| 514 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 515 | + | let (event, kind) = match f.action.as_str() { |
|
| 516 | + | "close" => { |
|
| 517 | + | idb::set_state(&app.db, i.id, "closed", "completed").await.map_err(e)?; |
|
| 518 | + | ("closed", "issue.closed") |
|
| 519 | + | } |
|
| 520 | + | "not_planned" => { |
|
| 521 | + | idb::set_state(&app.db, i.id, "closed", "not_planned").await.map_err(e)?; |
|
| 522 | + | ("closed as not planned", "issue.closed") |
|
| 523 | + | } |
|
| 524 | + | "reopen" => { |
|
| 525 | + | idb::set_state(&app.db, i.id, "open", "").await.map_err(e)?; |
|
| 526 | + | ("reopened", "issue.reopened") |
|
| 527 | + | } |
|
| 528 | + | "lock" if role >= Some(Role::Triage) => { |
|
| 529 | + | idb::set_flags(&app.db, i.id, Some(true), None).await.map_err(e)?; |
|
| 530 | + | ("locked", "") |
|
| 531 | + | } |
|
| 532 | + | "unlock" if role >= Some(Role::Triage) => { |
|
| 533 | + | idb::set_flags(&app.db, i.id, Some(false), None).await.map_err(e)?; |
|
| 534 | + | ("unlocked", "") |
|
| 535 | + | } |
|
| 536 | + | "pin" if role >= Some(Role::Triage) => { |
|
| 537 | + | idb::set_flags(&app.db, i.id, None, Some(true)).await.map_err(e)?; |
|
| 538 | + | ("pinned", "") |
|
| 539 | + | } |
|
| 540 | + | "unpin" if role >= Some(Role::Triage) => { |
|
| 541 | + | idb::set_flags(&app.db, i.id, None, Some(false)).await.map_err(e)?; |
|
| 542 | + | ("unpinned", "") |
|
| 543 | + | } |
|
| 544 | + | _ => return Err(Error::bad(shell, "unknown action")), |
|
| 545 | + | }; |
|
| 546 | + | idb::add_event(&app.db, i.id, &me.account.username, event, "").await.ok(); |
|
| 547 | + | if !kind.is_empty() { |
|
| 548 | + | let direct = i.author_id.into_iter().collect(); |
|
| 549 | + | notify::deliver( |
|
| 550 | + | &app, |
|
| 551 | + | Event { |
|
| 552 | + | kind, |
|
| 553 | + | title: format!("{owner}/{name}#{number}: {}", i.title), |
|
| 554 | + | url: format!("/{owner}/{name}/issue/{number}"), |
|
| 555 | + | actor: &me.account, |
|
| 556 | + | repo_id: row.id, |
|
| 557 | + | thread: Some(("issue", i.id)), |
|
| 558 | + | mention_text: "", |
|
| 559 | + | direct, |
|
| 560 | + | }, |
|
| 561 | + | ) |
|
| 562 | + | .await; |
|
| 563 | + | } |
|
| 564 | + | detail(&app, shell, &viewer, &owner, &name, number, None, None).await |
|
| 565 | + | } |
|
| 566 | + | ||
| 567 | + | #[derive(Deserialize)] |
|
| 568 | + | pub struct LabelsForm { |
|
| 569 | + | #[serde(rename = "_csrf")] |
|
| 570 | + | csrf_token: String, |
|
| 571 | + | #[serde(default)] |
|
| 572 | + | label: Vec<i64>, |
|
| 573 | + | } |
|
| 574 | + | ||
| 575 | + | pub async fn set_labels( |
|
| 576 | + | State(app): State<Shared>, |
|
| 577 | + | viewer: Viewer, |
|
| 578 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 579 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>, |
|
| 580 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 581 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 582 | + | let me = require(&viewer, &shell)?; |
|
| 583 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 584 | + | let (row, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; |
|
| 585 | + | let role = crate::auth::roles::role_for(&app.db, Some(&me.account), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 586 | + | if role < Some(Role::Triage) { |
|
| 587 | + | return Err(Error::not_found(shell)); |
|
| 588 | + | } |
|
| 589 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 590 | + | for l in idb::labels(&app.db, row.id).await.map_err(e)? { |
|
| 591 | + | idb::set_issue_label(&app.db, i.id, l.id, f.label.contains(&l.id)).await.map_err(e)?; |
|
| 592 | + | } |
|
| 593 | + | idb::add_event(&app.db, i.id, &me.account.username, "set labels", "").await.ok(); |
|
| 594 | + | detail(&app, shell, &viewer, &owner, &name, number, None, None).await |
|
| 595 | + | } |
|
| 596 | + | ||
| 597 | + | #[derive(Deserialize)] |
|
| 598 | + | pub struct AssignForm { |
|
| 599 | + | #[serde(rename = "_csrf")] |
|
| 600 | + | csrf_token: String, |
|
| 601 | + | username: String, |
|
| 602 | + | action: String, |
|
| 603 | + | } |
|
| 604 | + | ||
| 605 | + | pub async fn assign( |
|
| 606 | + | State(app): State<Shared>, |
|
| 607 | + | viewer: Viewer, |
|
| 608 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 609 | + | Form(f): Form<AssignForm>, |
|
| 610 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 611 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 612 | + | let me = require(&viewer, &shell)?; |
|
| 613 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 614 | + | let (row, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; |
|
| 615 | + | if role < Role::Triage { |
|
| 616 | + | return Err(Error::not_found(shell)); |
|
| 617 | + | } |
|
| 618 | + | let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { |
|
| 619 | + | return detail(&app, shell, &viewer, &owner, &name, number, None, Some("No such account.".into())).await; |
|
| 620 | + | }; |
|
| 621 | + | let on = f.action == "add"; |
|
| 622 | + | idb::set_assignee(&app.db, i.id, target.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 623 | + | idb::add_event(&app.db, i.id, &me.account.username, if on { "assigned" } else { "unassigned" }, &target.username).await.ok(); |
|
| 624 | + | if on { |
|
| 625 | + | notify::deliver( |
|
| 626 | + | &app, |
|
| 627 | + | Event { |
|
| 628 | + | kind: "issue.assigned", |
|
| 629 | + | title: format!("{owner}/{name}#{number}: {}", i.title), |
|
| 630 | + | url: format!("/{owner}/{name}/issue/{number}"), |
|
| 631 | + | actor: &me.account, |
|
| 632 | + | repo_id: row.id, |
|
| 633 | + | thread: None, |
|
| 634 | + | mention_text: "", |
|
| 635 | + | direct: vec![target.id], |
|
| 636 | + | }, |
|
| 637 | + | ) |
|
| 638 | + | .await; |
|
| 639 | + | ndb::subscribe(&app.db, target.id, "issue", i.id).await.ok(); |
|
| 640 | + | } |
|
| 641 | + | detail(&app, shell, &viewer, &owner, &name, number, None, None).await |
|
| 642 | + | } |
|
| 643 | + | ||
| 644 | + | #[derive(Deserialize)] |
|
| 645 | + | pub struct MilestoneForm { |
|
| 646 | + | #[serde(rename = "_csrf")] |
|
| 647 | + | csrf_token: String, |
|
| 648 | + | #[serde(default)] |
|
| 649 | + | milestone: String, |
|
| 650 | + | } |
|
| 651 | + | ||
| 652 | + | pub async fn set_milestone( |
|
| 653 | + | State(app): State<Shared>, |
|
| 654 | + | viewer: Viewer, |
|
| 655 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 656 | + | Form(f): Form<MilestoneForm>, |
|
| 657 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 658 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 659 | + | let me = require(&viewer, &shell)?; |
|
| 660 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 661 | + | let (_, i, role) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; |
|
| 662 | + | if role < Role::Triage { |
|
| 663 | + | return Err(Error::not_found(shell)); |
|
| 664 | + | } |
|
| 665 | + | let id = f.milestone.trim().parse::<i64>().ok(); |
|
| 666 | + | idb::set_milestone(&app.db, i.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 667 | + | idb::add_event(&app.db, i.id, &me.account.username, "set milestone", "").await.ok(); |
|
| 668 | + | detail(&app, shell, &viewer, &owner, &name, number, None, None).await |
|
| 669 | + | } |
|
| 670 | + | ||
| 671 | + | pub async fn subscribe( |
|
| 672 | + | State(app): State<Shared>, |
|
| 673 | + | viewer: Viewer, |
|
| 674 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 675 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 676 | + | ) -> Result<Page<IssuePage>, Error> { |
|
| 677 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/issue/{number}"), &viewer); |
|
| 678 | + | let me = require(&viewer, &shell)?; |
|
| 679 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 680 | + | let (_, i, _) = issue_for(&app, &shell, &viewer, &owner, &name, number, Access::Read).await?; |
|
| 681 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 682 | + | if ndb::subscribed(&app.db, me.account.id, "issue", i.id).await.map_err(e)? { |
|
| 683 | + | ndb::unsubscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?; |
|
| 684 | + | } else { |
|
| 685 | + | ndb::subscribe(&app.db, me.account.id, "issue", i.id).await.map_err(e)?; |
|
| 686 | + | } |
|
| 687 | + | detail(&app, shell, &viewer, &owner, &name, number, None, None).await |
|
| 688 | + | } |
|
| 689 | + | ||
| 690 | + | // ---------------------------------------------------------------- labels |
|
| 691 | + | ||
| 692 | + | #[derive(Template)] |
|
| 693 | + | #[template(path = "labels.html")] |
|
| 694 | + | pub struct LabelsPage { |
|
| 695 | + | shell: Shell, |
|
| 696 | + | repo: RepoShell, |
|
| 697 | + | labels: Vec<idb::Label>, |
|
| 698 | + | can_triage: bool, |
|
| 699 | + | notice: Option<String>, |
|
| 700 | + | } |
|
| 701 | + | ||
| 702 | + | pub async fn labels( |
|
| 703 | + | State(app): State<Shared>, |
|
| 704 | + | viewer: Viewer, |
|
| 705 | + | Path((owner, name)): Path<(String, String)>, |
|
| 706 | + | ) -> Result<Page<LabelsPage>, Error> { |
|
| 707 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer); |
|
| 708 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 709 | + | let labels = idb::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 710 | + | Ok(Page(LabelsPage { |
|
| 711 | + | shell, |
|
| 712 | + | repo: RepoShell::new(&app, &row, "issues").with_role(role), |
|
| 713 | + | labels, |
|
| 714 | + | can_triage: role >= Role::Triage, |
|
| 715 | + | notice: None, |
|
| 716 | + | })) |
|
| 717 | + | } |
|
| 718 | + | ||
| 719 | + | #[derive(Deserialize)] |
|
| 720 | + | pub struct LabelForm { |
|
| 721 | + | #[serde(rename = "_csrf")] |
|
| 722 | + | csrf_token: String, |
|
| 723 | + | name: String, |
|
| 724 | + | #[serde(default = "default_colour")] |
|
| 725 | + | colour: String, |
|
| 726 | + | #[serde(default)] |
|
| 727 | + | description: String, |
|
| 728 | + | #[serde(default)] |
|
| 729 | + | delete: Option<String>, |
|
| 730 | + | } |
|
| 731 | + | ||
| 732 | + | fn default_colour() -> String { |
|
| 733 | + | "#0072ff".into() |
|
| 734 | + | } |
|
| 735 | + | ||
| 736 | + | pub async fn save_label( |
|
| 737 | + | State(app): State<Shared>, |
|
| 738 | + | viewer: Viewer, |
|
| 739 | + | Path((owner, name)): Path<(String, String)>, |
|
| 740 | + | Form(f): Form<LabelForm>, |
|
| 741 | + | ) -> Result<Page<LabelsPage>, Error> { |
|
| 742 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer); |
|
| 743 | + | require(&viewer, &shell)?; |
|
| 744 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 745 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 746 | + | if role < Role::Triage { |
|
| 747 | + | return Err(Error::not_found(shell)); |
|
| 748 | + | } |
|
| 749 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 750 | + | let notice = if f.delete.is_some() { |
|
| 751 | + | if let Some(l) = idb::label_by_name(&app.db, row.id, f.name.trim()).await.map_err(e)? { |
|
| 752 | + | idb::delete_label(&app.db, l.id).await.map_err(e)?; |
|
| 753 | + | } |
|
| 754 | + | format!("Deleted {}.", f.name.trim()) |
|
| 755 | + | } else { |
|
| 756 | + | let colour = if regex::Regex::new(r"^#[0-9a-fA-F]{6}$").unwrap().is_match(&f.colour) { f.colour.clone() } else { default_colour() }; |
|
| 757 | + | idb::upsert_label(&app.db, row.id, f.name.trim(), &colour, f.description.trim()).await.map_err(e)?; |
|
| 758 | + | format!("Saved {}.", f.name.trim()) |
|
| 759 | + | }; |
|
| 760 | + | let labels = idb::labels(&app.db, row.id).await.map_err(e)?; |
|
| 761 | + | Ok(Page(LabelsPage { |
|
| 762 | + | shell, |
|
| 763 | + | repo: RepoShell::new(&app, &row, "issues").with_role(role), |
|
| 764 | + | labels, |
|
| 765 | + | can_triage: true, |
|
| 766 | + | notice: Some(notice), |
|
| 767 | + | })) |
|
| 768 | + | } |
|
| 769 | + | ||
| 770 | + | // ------------------------------------------------------------ milestones |
|
| 771 | + | ||
| 772 | + | #[derive(Template)] |
|
| 773 | + | #[template(path = "milestones.html")] |
|
| 774 | + | pub struct MilestonesPage { |
|
| 775 | + | shell: Shell, |
|
| 776 | + | repo: RepoShell, |
|
| 777 | + | rows: Vec<(idb::Milestone, i64, i64)>, |
|
| 778 | + | can_triage: bool, |
|
| 779 | + | notice: Option<String>, |
|
| 780 | + | } |
|
| 781 | + | ||
| 782 | + | async fn milestones_page( |
|
| 783 | + | app: &Shared, |
|
| 784 | + | shell: Shell, |
|
| 785 | + | row: db::repos::RepoRow, |
|
| 786 | + | role: Role, |
|
| 787 | + | notice: Option<String>, |
|
| 788 | + | ) -> Result<Page<MilestonesPage>, Error> { |
|
| 789 | + | let mut rows = Vec::new(); |
|
| 790 | + | for m in idb::milestones(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 791 | + | let (open, closed) = idb::milestone_counts(&app.db, m.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 792 | + | rows.push((m, open, closed)); |
|
| 793 | + | } |
|
| 794 | + | Ok(Page(MilestonesPage { |
|
| 795 | + | shell, |
|
| 796 | + | repo: RepoShell::new(app, &row, "issues").with_role(role), |
|
| 797 | + | rows, |
|
| 798 | + | can_triage: role >= Role::Triage, |
|
| 799 | + | notice, |
|
| 800 | + | })) |
|
| 801 | + | } |
|
| 802 | + | ||
| 803 | + | pub async fn milestones( |
|
| 804 | + | State(app): State<Shared>, |
|
| 805 | + | viewer: Viewer, |
|
| 806 | + | Path((owner, name)): Path<(String, String)>, |
|
| 807 | + | ) -> Result<Page<MilestonesPage>, Error> { |
|
| 808 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer); |
|
| 809 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 810 | + | milestones_page(&app, shell, row, role, None).await |
|
| 811 | + | } |
|
| 812 | + | ||
| 813 | + | #[derive(Deserialize)] |
|
| 814 | + | pub struct MilestoneSaveForm { |
|
| 815 | + | #[serde(rename = "_csrf")] |
|
| 816 | + | csrf_token: String, |
|
| 817 | + | title: String, |
|
| 818 | + | #[serde(default)] |
|
| 819 | + | description: String, |
|
| 820 | + | #[serde(default)] |
|
| 821 | + | due: String, |
|
| 822 | + | #[serde(default)] |
|
| 823 | + | state: Option<String>, |
|
| 824 | + | } |
|
| 825 | + | ||
| 826 | + | pub async fn save_milestone( |
|
| 827 | + | State(app): State<Shared>, |
|
| 828 | + | viewer: Viewer, |
|
| 829 | + | Path((owner, name)): Path<(String, String)>, |
|
| 830 | + | Form(f): Form<MilestoneSaveForm>, |
|
| 831 | + | ) -> Result<Page<MilestonesPage>, Error> { |
|
| 832 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/milestones"), &viewer); |
|
| 833 | + | require(&viewer, &shell)?; |
|
| 834 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 835 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 836 | + | if role < Role::Triage { |
|
| 837 | + | return Err(Error::not_found(shell)); |
|
| 838 | + | } |
|
| 839 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 840 | + | if let Some(state) = f.state.as_deref() { |
|
| 841 | + | if let Some(m) = idb::milestones(&app.db, row.id).await.map_err(e)?.into_iter().find(|m| m.title == f.title) { |
|
| 842 | + | idb::set_milestone_state(&app.db, m.id, if state == "closed" { "closed" } else { "open" }).await.map_err(e)?; |
|
| 843 | + | } |
|
| 844 | + | return milestones_page(&app, shell, row, role, Some("Updated.".into())).await; |
|
| 845 | + | } |
|
| 846 | + | let due_ms = time::Date::parse(f.due.trim(), &time::macros::format_description!("[year]-[month]-[day]")) |
|
| 847 | + | .ok() |
|
| 848 | + | .map(|d| d.midnight().assume_utc().unix_timestamp() * 1000); |
|
| 849 | + | idb::upsert_milestone(&app.db, row.id, f.title.trim(), f.description.trim(), due_ms).await.map_err(e)?; |
|
| 850 | + | milestones_page(&app, shell, row, role, Some("Saved.".into())).await |
|
| 851 | + | } |
|
| 852 | + | ||
| 853 | + | // ----------------------------------------------------------------- watch |
|
| 854 | + | ||
| 855 | + | pub async fn watch( |
|
| 856 | + | State(app): State<Shared>, |
|
| 857 | + | viewer: Viewer, |
|
| 858 | + | Path((owner, name)): Path<(String, String)>, |
|
| 859 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 860 | + | ) -> Result<Response, Error> { |
|
| 861 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer); |
|
| 862 | + | let me = require(&viewer, &shell)?; |
|
| 863 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 864 | + | let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 865 | + | let on = !ndb::watching(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 866 | + | ndb::set_watch(&app.db, me.account.id, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 867 | + | Ok(Redirect::to(&format!("/{owner}/{name}")).into_response()) |
|
| 868 | + | } |
|
| 869 | + | ||
| 870 | + | /// Close issues a landed change's message names (`closes #12`), when the |
|
| 871 | + | /// landing reached the default bookmark. Called from the push hook and |
|
| 872 | + | /// the hub's own landing. |
|
| 873 | + | pub async fn close_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)]) { |
|
| 874 | + | for (hash, message) in messages { |
|
| 875 | + | for n in render::refs::closes(message) { |
|
| 876 | + | if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await { |
|
| 877 | + | if i.state == "open" { |
|
| 878 | + | idb::set_state(&app.db, i.id, "closed", "completed").await.ok(); |
|
| 879 | + | idb::add_event(&app.db, i.id, "", "closed by change", &hash[..12.min(hash.len())]).await.ok(); |
|
| 880 | + | for id in ndb::subscribers(&app.db, "issue", i.id).await.unwrap_or_default() { |
|
| 881 | + | ndb::add( |
|
| 882 | + | &app.db, |
|
| 883 | + | id, |
|
| 884 | + | "issue.closed", |
|
| 885 | + | &format!("{}#{}: {} (closed by {})", row.slug(), i.number, i.title, &hash[..12.min(hash.len())]), |
|
| 886 | + | &format!("/{}/issue/{}", row.slug(), i.number), |
|
| 887 | + | "", |
|
| 888 | + | ) |
|
| 889 | + | .await |
|
| 890 | + | .ok(); |
|
| 891 | + | } |
|
| 892 | + | } |
|
| 893 | + | } |
|
| 894 | + | } |
|
| 895 | + | } |
|
| 896 | + | } |
added crates/hub/tests/issues.rs +248 −0
| 1 | + | //! Milestone 0.4 end to end: issues with labels, assignees and |
|
| 2 | + | //! milestones, cross references, closing keywords on landing, the inbox, |
|
| 3 | + | //! watching, mentions and search. |
|
| 4 | + | ||
| 5 | + | use axum::body::Body; |
|
| 6 | + | use axum::http::{header, Request, StatusCode}; |
|
| 7 | + | use http_body_util::BodyExt; |
|
| 8 | + | use oxy_core::repo::Repo; |
|
| 9 | + | use oxygenhub::config::Config; |
|
| 10 | + | use oxygenhub::db; |
|
| 11 | + | use oxygenhub::repos::RepoName; |
|
| 12 | + | use oxygenhub::Shared; |
|
| 13 | + | use std::path::Path; |
|
| 14 | + | use tower::ServiceExt; |
|
| 15 | + | ||
| 16 | + | fn hermetic() { |
|
| 17 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 18 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 19 | + | ONCE.call_once(|| { |
|
| 20 | + | let d = tempfile::tempdir().unwrap(); |
|
| 21 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 22 | + | DIR.set(d).ok(); |
|
| 23 | + | }); |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | fn write(root: &Path, rel: &str, content: &[u8]) { |
|
| 27 | + | let p = root.join(rel); |
|
| 28 | + | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| 29 | + | std::fs::write(p, content).unwrap(); |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | struct Hub { |
|
| 33 | + | app: Shared, |
|
| 34 | + | base: String, |
|
| 35 | + | _dir: tempfile::TempDir, |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | async fn hub() -> Hub { |
|
| 39 | + | hermetic(); |
|
| 40 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 41 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 42 | + | config.auth.push_tokens = vec!["site".into()]; |
|
| 43 | + | config.registration.mode = "open".into(); |
|
| 44 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 45 | + | let port = listener.local_addr().unwrap().port(); |
|
| 46 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 47 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 48 | + | let router = oxygenhub::router(app.clone()); |
|
| 49 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 50 | + | Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | struct Res { |
|
| 54 | + | status: StatusCode, |
|
| 55 | + | body: String, |
|
| 56 | + | cookie: Option<String>, |
|
| 57 | + | location: Option<String>, |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
|
| 61 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 62 | + | if let Some(c) = cookie { |
|
| 63 | + | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 64 | + | } |
|
| 65 | + | let body = match form { |
|
| 66 | + | Some(f) => { |
|
| 67 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 68 | + | Body::from(f.to_string()) |
|
| 69 | + | } |
|
| 70 | + | None => Body::empty(), |
|
| 71 | + | }; |
|
| 72 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 73 | + | let status = res.status(); |
|
| 74 | + | let cookie = res |
|
| 75 | + | .headers() |
|
| 76 | + | .get(header::SET_COOKIE) |
|
| 77 | + | .and_then(|v| v.to_str().ok()) |
|
| 78 | + | .and_then(|v| v.split(';').next()) |
|
| 79 | + | .and_then(|kv| kv.split_once('=')) |
|
| 80 | + | .map(|(_, v)| v.to_string()); |
|
| 81 | + | let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
|
| 82 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 83 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
|
| 87 | + | send(app, "GET", path, cookie, None).await |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
|
| 91 | + | send(app, "POST", path, cookie, Some(form)).await |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | async fn register(app: &Shared, user: &str) -> String { |
|
| 95 | + | let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await; |
|
| 96 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 97 | + | r.cookie.unwrap() |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | fn csrf_of(page: &str) -> String { |
|
| 101 | + | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 102 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 106 | + | async fn issues_notifications_and_search() { |
|
| 107 | + | let h = hub().await; |
|
| 108 | + | let app = &h.app; |
|
| 109 | + | let andy = register(app, "andy").await; |
|
| 110 | + | let bob = register(app, "bob").await; |
|
| 111 | + | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 112 | + | app.repos.create(&repo, None).unwrap(); |
|
| 113 | + | db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 114 | + | ||
| 115 | + | // Labels and a milestone, by the owner. |
|
| 116 | + | let labels_page = get(app, "/andy/demo/labels", Some(&andy)).await; |
|
| 117 | + | let csrf_a = csrf_of(&labels_page.body); |
|
| 118 | + | assert!(post(app, "/andy/demo/labels", Some(&andy), &format!("_csrf={csrf_a}&name=bug&colour=%23ff0000&description=Something+broke")) |
|
| 119 | + | .await |
|
| 120 | + | .body |
|
| 121 | + | .contains("Saved bug")); |
|
| 122 | + | assert!(post(app, "/andy/demo/milestones", Some(&andy), &format!("_csrf={csrf_a}&title=v1&description=First&due=2027-01-01")) |
|
| 123 | + | .await |
|
| 124 | + | .body |
|
| 125 | + | .contains("v1")); |
|
| 126 | + | assert_eq!(get(app, "/andy/demo/labels", Some(&bob)).await.status, StatusCode::OK, "readers see labels"); |
|
| 127 | + | assert!(!get(app, "/andy/demo/labels", Some(&bob)).await.body.contains("Add or change"), "but cannot edit them"); |
|
| 128 | + | ||
| 129 | + | // Bob watches the repository, then Andy opens an issue mentioning @bob: Bob is told twice at most once. |
|
| 130 | + | let overview = get(app, "/andy/demo", Some(&bob)).await; |
|
| 131 | + | let csrf_b = csrf_of(&overview.body); |
|
| 132 | + | assert_eq!(post(app, "/andy/demo/watch", Some(&bob), &format!("_csrf={csrf_b}")).await.status, StatusCode::SEE_OTHER); |
|
| 133 | + | assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("UNWATCH")); |
|
| 134 | + | let new_page = get(app, "/andy/demo/issues/new", Some(&andy)).await; |
|
| 135 | + | let csrf_a = csrf_of(&new_page.body); |
|
| 136 | + | let r = post( |
|
| 137 | + | app, |
|
| 138 | + | "/andy/demo/issues/new", |
|
| 139 | + | Some(&andy), |
|
| 140 | + | &format!("_csrf={csrf_a}&title=Crash+on+start&body=cc+%40bob+see+%23999+and+deadbeefcafe1234"), |
|
| 141 | + | ) |
|
| 142 | + | .await; |
|
| 143 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 144 | + | assert_eq!(r.location.as_deref(), Some("/andy/demo/issue/1")); |
|
| 145 | + | let page = get(app, "/andy/demo/issue/1", Some(&bob)).await; |
|
| 146 | + | assert!(page.body.contains("Crash on start")); |
|
| 147 | + | assert!(page.body.contains("href=\"/bob\""), "mention linked: {}", page.body); |
|
| 148 | + | assert!(page.body.contains("/andy/demo/change/deadbeefcafe1234"), "hash linked: {}", page.body); |
|
| 149 | + | assert!(page.body.contains("#999") && !page.body.contains("issue/999"), "unknown numbers are not linked"); |
|
| 150 | + | let unread = db::notify::unread(&app.db, db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id).await.unwrap(); |
|
| 151 | + | assert_eq!(unread, 1, "one notification, not one per reason"); |
|
| 152 | + | let inbox = get(app, "/notifications", Some(&bob)).await; |
|
| 153 | + | assert!(inbox.body.contains("andy/demo#1: Crash on start"), "{}", inbox.body); |
|
| 154 | + | assert!(get(app, "/andy/demo", Some(&bob)).await.body.contains("class=\"count\">1<"), "bell shows the count"); |
|
| 155 | + | ||
| 156 | + | // Triage: labels, assignee, milestone, pin, lock. |
|
| 157 | + | let page = get(app, "/andy/demo/issue/1", Some(&andy)).await; |
|
| 158 | + | let csrf_a = csrf_of(&page.body); |
|
| 159 | + | let label_id: i64 = sqlx::query_scalar("SELECT id FROM labels WHERE name = 'bug'").fetch_one(&app.db).await.unwrap(); |
|
| 160 | + | let r = post(app, "/andy/demo/issue/1/labels", Some(&andy), &format!("_csrf={csrf_a}&label={label_id}")).await; |
|
| 161 | + | assert!(r.body.contains(">bug<"), "{}", r.body); |
|
| 162 | + | let r = post(app, "/andy/demo/issue/1/assign", Some(&andy), &format!("_csrf={csrf_a}&username=bob&action=add")).await; |
|
| 163 | + | assert!(r.body.contains("href=\"/bob\">bob</a>"), "{}", r.body); |
|
| 164 | + | let ms_id: i64 = sqlx::query_scalar("SELECT id FROM milestones WHERE title = 'v1'").fetch_one(&app.db).await.unwrap(); |
|
| 165 | + | let r = post(app, "/andy/demo/issue/1/milestone", Some(&andy), &format!("_csrf={csrf_a}&milestone={ms_id}")).await; |
|
| 166 | + | assert!(r.body.contains(">v1<"), "{}", r.body); |
|
| 167 | + | assert!(post(app, "/andy/demo/issue/1/state", Some(&andy), &format!("_csrf={csrf_a}&action=pin")).await.body.contains("UNPIN")); |
|
| 168 | + | assert_eq!( |
|
| 169 | + | post( |
|
| 170 | + | app, |
|
| 171 | + | "/andy/demo/issue/1/state", |
|
| 172 | + | Some(&bob), |
|
| 173 | + | &format!("_csrf={}&action=lock", csrf_of(&get(app, "/andy/demo/issue/1", Some(&bob)).await.body)) |
|
| 174 | + | ) |
|
| 175 | + | .await |
|
| 176 | + | .status, |
|
| 177 | + | StatusCode::NOT_FOUND, |
|
| 178 | + | "bob cannot lock" |
|
| 179 | + | ); |
|
| 180 | + | // Filters on the list. |
|
| 181 | + | let list = get(app, "/andy/demo/issues?q=label:bug+assignee:bob+milestone:v1", None).await; |
|
| 182 | + | assert!(list.body.contains("Crash on start"), "{}", list.body); |
|
| 183 | + | assert!(!get(app, "/andy/demo/issues?q=label:feature", None).await.body.contains("Crash on start")); |
|
| 184 | + | assert!(get(app, "/andy/demo/milestones", None).await.body.contains("1 open, 0 closed")); |
|
| 185 | + | ||
| 186 | + | // Bob comments and closes; the author is told; search finds the comment. |
|
| 187 | + | let csrf_b = csrf_of(&get(app, "/andy/demo/issue/1", Some(&bob)).await.body); |
|
| 188 | + | let r = post(app, "/andy/demo/issue/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=Reproduced+with+the+flux+capacitor")).await; |
|
| 189 | + | assert!(r.body.contains("flux capacitor"), "{}", r.body); |
|
| 190 | + | assert!(get(app, "/notifications", Some(&andy)).await.body.contains("andy/demo#1")); |
|
| 191 | + | let s = get(app, "/search?q=capacitor", None).await; |
|
| 192 | + | assert!(s.body.contains("Crash on start") && s.body.contains("<mark>capacitor</mark>"), "{}", s.body); |
|
| 193 | + | let s = get(app, "/search?q=demo", None).await; |
|
| 194 | + | assert!(s.body.contains("/andy/demo\""), "repository hit: {}", s.body); |
|
| 195 | + | // Bob is not triage, but as an assignee of an open issue he can comment-and-close? No: only the author or triage. |
|
| 196 | + | let r = post(app, "/andy/demo/issue/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=closing&close=completed")).await; |
|
| 197 | + | assert!(r.body.contains(">open<") || r.body.contains("head\">open"), "still open: {}", r.body); |
|
| 198 | + | let r = post(app, "/andy/demo/issue/1/comment", Some(&andy), &format!("_csrf={csrf_a}&body=fixed&close=completed")).await; |
|
| 199 | + | assert!(r.body.contains("closed"), "{}", r.body); |
|
| 200 | + | assert!(get(app, "/andy/demo/issues?state=closed", None).await.body.contains("Crash on start")); |
|
| 201 | + | ||
| 202 | + | // A second issue closed by a change message pushed to the default bookmark. |
|
| 203 | + | let csrf_a = csrf_of(&get(app, "/andy/demo/issues/new", Some(&andy)).await.body); |
|
| 204 | + | assert_eq!( |
|
| 205 | + | post(app, "/andy/demo/issues/new", Some(&andy), &format!("_csrf={csrf_a}&title=Typo&body=")).await.location.as_deref(), |
|
| 206 | + | Some("/andy/demo/issue/2") |
|
| 207 | + | ); |
|
| 208 | + | let work = tempfile::tempdir().unwrap(); |
|
| 209 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 210 | + | client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
|
| 211 | + | client.config.set("remote.origin.token", "site").unwrap(); |
|
| 212 | + | client.save_config().unwrap(); |
|
| 213 | + | write(work.path(), "a.txt", b"a\n"); |
|
| 214 | + | client.snapshot("t").unwrap(); |
|
| 215 | + | client.commit("Fix the typo\n\nCloses #2", &[]).unwrap(); |
|
| 216 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 217 | + | tokio::time::sleep(std::time::Duration::from_millis(300)).await; |
|
| 218 | + | let issue2 = db::issues::get(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, 2).await.unwrap().unwrap(); |
|
| 219 | + | assert_eq!(issue2.state, "closed", "closed by the landed change"); |
|
| 220 | + | assert!(get(app, "/andy/demo/issue/2", None).await.body.contains("closed by change")); |
|
| 221 | + | ||
| 222 | + | // Numbers are shared: the next proposal is #3, and /issue/3 redirects to it. |
|
| 223 | + | client.bookmark_create("feat", None).unwrap(); |
|
| 224 | + | client.bookmark_switch("feat", false).unwrap(); |
|
| 225 | + | write(work.path(), "b.txt", b"b\n"); |
|
| 226 | + | client.snapshot("t").unwrap(); |
|
| 227 | + | client.commit("feat", &[]).unwrap(); |
|
| 228 | + | assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok)); |
|
| 229 | + | let csrf_a = csrf_of(&get(app, "/andy/demo/proposals/new?source=feat&target=main", Some(&andy)).await.body); |
|
| 230 | + | let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf_a}&title=Feat&target=main&source=feat")).await; |
|
| 231 | + | assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/3"), "{}", r.body); |
|
| 232 | + | assert_eq!(get(app, "/andy/demo/issue/3", None).await.location.as_deref(), Some("/andy/demo/proposal/3")); |
|
| 233 | + | // A comment on the issue referencing #3 links to the proposal. |
|
| 234 | + | let r = post(app, "/andy/demo/issue/2/comment", Some(&andy), &format!("_csrf={csrf_a}&body=see+%233")).await; |
|
| 235 | + | assert!(r.body.contains("href=\"/andy/demo/proposal/3\""), "{}", r.body); |
|
| 236 | + | // Bob (watcher) heard about the proposal; the inbox marks all read. |
|
| 237 | + | assert!(get(app, "/notifications", Some(&bob)).await.body.contains("andy/demo#3: Feat")); |
|
| 238 | + | let csrf_b = csrf_of(&get(app, "/notifications", Some(&bob)).await.body); |
|
| 239 | + | let r = post(app, "/notifications/read", Some(&bob), &format!("_csrf={csrf_b}")).await; |
|
| 240 | + | assert!(r.body.contains("0 unread"), "{}", r.body); |
|
| 241 | + | // Email preference and the unsubscribe link. |
|
| 242 | + | let r = post(app, "/settings/notifications", Some(&bob), &format!("_csrf={csrf_b}&email=1")).await; |
|
| 243 | + | assert!(r.body.contains("Email notifications are on")); |
|
| 244 | + | let key: String = sqlx::query_scalar("SELECT unsubscribe_key FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap(); |
|
| 245 | + | assert_eq!(get(app, &format!("/settings/notifications?unsubscribe={key}"), None).await.status, StatusCode::OK); |
|
| 246 | + | let on: bool = sqlx::query_scalar("SELECT email_notifications FROM accounts WHERE username = 'bob'").fetch_one(&app.db).await.unwrap(); |
|
| 247 | + | assert!(!on); |
|
| 248 | + | } |
added templates/issue.html +67 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}#{{ i.number }} {{ i.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "issues") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>{{ i.title }} <small class="px">#{{ i.number }}</small> |
|
| 8 | + | <small><span class="tag{% if i.state == "closed" %} bad{% else %} head{% endif %}">{{ i.state }}{% if !i.reason.is_empty() %}: {{ i.reason.replace("_", " ") }}{% endif %}</span> {{ i.author }} opened this <time title="{{ i.created_ms|when_i }}">{{ i.created_ms|ago_i }}</time> · {{ comments.len()|plural("comment", "comments") }}{% if i.locked %} · <span class="tag">locked</span>{% endif %}</small> |
|
| 9 | + | </h1></div> |
|
| 10 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 11 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 12 | + | <div class="side-layout"> |
|
| 13 | + | <div> |
|
| 14 | + | <div class="comment"><span class="who">{{ i.author }}</span> <time class="mute" title="{{ i.created_ms|when_i }}">{{ i.created_ms|ago_i }}</time> |
|
| 15 | + | {% if can_edit %}<details class="inline-edit"><summary class="small">edit</summary><form method="post" action="{{ repo.url }}/issue/{{ i.number }}/edit"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="title" value="{{ i.title }}"><textarea name="body" rows="6">{{ i.body }}</textarea><button type="submit">SAVE</button></form></details>{% endif %} |
|
| 16 | + | <div class="body">{% if i.body.is_empty() %}<p class="mute">No description.</p>{% else %}{{ body_html|safe }}{% endif %}</div></div> |
|
| 17 | + | {% for item in timeline %} |
|
| 18 | + | {% match item %} |
|
| 19 | + | {% when crate::web::issues::Item::Comment with (c, html) %} |
|
| 20 | + | <div class="comment" id="c{{ c.id }}"><span class="who">{{ c.author }}</span> <time class="mute" title="{{ c.created_ms|when_i }}">{{ c.created_ms|ago_i }}</time>{% if c.edited_ms.is_some() %} <span class="mute small">edited</span>{% endif %} |
|
| 21 | + | {% if let Some(u) = shell.user %}{% if *u == c.author || can_triage %}<form class="tiny" method="post" action="{{ repo.url }}/issue/{{ i.number }}/comment/{{ c.id }}/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>delete</button></form>{% endif %}{% endif %} |
|
| 22 | + | <div class="body">{{ html|safe }}</div></div> |
|
| 23 | + | {% when crate::web::issues::Item::Event with (e) %} |
|
| 24 | + | <p class="small mute timeline">{{ e.actor }} {{ e.event }}{% if !e.detail.is_empty() %} {{ e.detail }}{% endif %} · <time title="{{ e.at_ms|when_i }}">{{ e.at_ms|ago_i }}</time></p> |
|
| 25 | + | {% endmatch %} |
|
| 26 | + | {% endfor %} |
|
| 27 | + | {% if shell.user.is_some() && (!i.locked || can_triage) %} |
|
| 28 | + | <form class="card wide" method="post" action="{{ repo.url }}/issue/{{ i.number }}/comment"> |
|
| 29 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 30 | + | <label for="body">Comment (Markdown)</label><textarea id="body" name="body" class="md" rows="5" required></textarea> |
|
| 31 | + | <div class="choice"> |
|
| 32 | + | <button type="submit">COMMENT</button> |
|
| 33 | + | {% if i.state == "open" %}<button type="submit" name="close" value="completed">COMMENT AND CLOSE</button>{% else %}<button type="submit" name="reopen" value="1">COMMENT AND REOPEN</button>{% endif %} |
|
| 34 | + | </div> |
|
| 35 | + | </form> |
|
| 36 | + | {% else if i.locked %}<p class="empty">This issue is locked.</p>{% endif %} |
|
| 37 | + | </div> |
|
| 38 | + | <aside class="side"> |
|
| 39 | + | {% if shell.user.is_some() %} |
|
| 40 | + | <h3>Subscription</h3> |
|
| 41 | + | <form method="post" action="{{ repo.url }}/issue/{{ i.number }}/subscribe"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if subscribed %}UNSUBSCRIBE{% else %}SUBSCRIBE{% endif %}</button></form> |
|
| 42 | + | {% endif %} |
|
| 43 | + | <h3>Labels</h3> |
|
| 44 | + | {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}" title="{{ l.description }}">{{ l.name }}</span> {% endfor %}</p>{% endif %} |
|
| 45 | + | {% if can_triage && !all_labels.is_empty() %} |
|
| 46 | + | <form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 47 | + | {% 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 %} |
|
| 48 | + | <button type="submit" class="small">SET</button></form> |
|
| 49 | + | {% endif %} |
|
| 50 | + | <h3>Assignees</h3> |
|
| 51 | + | {% if assignees.is_empty() %}<p class="mute small">Nobody.</p>{% else %}<ul class="plain">{% for a in assignees %}<li><a href="/{{ a }}">{{ a }}</a></li>{% endfor %}</ul>{% endif %} |
|
| 52 | + | {% if can_triage %}<form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/assign"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="username" placeholder="username" size="12"> <button type="submit" name="action" value="add" class="small">ADD</button> <button type="submit" name="action" value="remove" class="small">REMOVE</button></form>{% endif %} |
|
| 53 | + | <h3>Milestone</h3> |
|
| 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 | + | {% 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 | + | {% if can_triage %} |
|
| 57 | + | <h3>Moderation</h3> |
|
| 58 | + | <form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 59 | + | <div class="choice"> |
|
| 60 | + | {% if i.state == "open" %}<button type="submit" name="action" value="close" class="small">CLOSE</button><button type="submit" name="action" value="not_planned" class="small">NOT PLANNED</button>{% else %}<button type="submit" name="action" value="reopen" class="small">REOPEN</button>{% endif %} |
|
| 61 | + | <button type="submit" name="action" value="{% if i.locked %}unlock{% else %}lock{% endif %}" class="small">{% if i.locked %}UNLOCK{% else %}LOCK{% endif %}</button> |
|
| 62 | + | <button type="submit" name="action" value="{% if i.pinned %}unpin{% else %}pin{% endif %}" class="small">{% if i.pinned %}UNPIN{% else %}PIN{% endif %}</button> |
|
| 63 | + | </div></form> |
|
| 64 | + | {% endif %} |
|
| 65 | + | </aside> |
|
| 66 | + | </div> |
|
| 67 | + | {% endblock %} |
added templates/issue_new.html +19 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}New issue · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "issues") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>New issue <small>{{ repo.owner }}/{{ repo.name }}</small></h1></div> |
|
| 8 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | + | {% if !templates.is_empty() && template.is_empty() %} |
|
| 10 | + | <section class="panel"><h2>Templates</h2><ul class="plain">{% for t in templates %}<li><a href="?template={{ t.0 }}">{{ t.0 }}</a></li>{% endfor %}<li><a href="?template=blank">blank</a></li></ul></section> |
|
| 11 | + | {% else %} |
|
| 12 | + | <form class="card wide" method="post" action="{{ repo.url }}/issues/new"> |
|
| 13 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 14 | + | <label for="title">Title</label><input type="text" id="title" name="title" required autofocus> |
|
| 15 | + | <label for="body">Description (Markdown; #12, owner/name#12, @user and change hashes become links)</label><textarea id="body" name="body" class="md" rows="12">{{ template }}</textarea> |
|
| 16 | + | <button type="submit">OPEN ISSUE</button> |
|
| 17 | + | </form> |
|
| 18 | + | {% endif %} |
|
| 19 | + | {% endblock %} |
added templates/issues.html +34 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Issues · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "issues") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>Issues <small>{{ counts.0 }} open · {{ counts.1 }} closed</small></h1></div> |
|
| 8 | + | <div class="bar"> |
|
| 9 | + | <form class="find" method="get" action="{{ repo.url }}/issues"><input type="search" name="q" value="{{ q }}" placeholder="Filter: text, label:bug, assignee:andy, milestone:v1, state:closed"></form> |
|
| 10 | + | <span class="modes"> |
|
| 11 | + | <a href="?state=open"{% if state == "open" %} class="on"{% endif %}>open</a> |
|
| 12 | + | <a href="?state=closed"{% if state == "closed" %} class="on"{% endif %}>closed</a> |
|
| 13 | + | <a href="?state=all"{% if state == "all" %} class="on"{% endif %}>all</a> |
|
| 14 | + | </span> |
|
| 15 | + | <a class="go small" href="{{ repo.url }}/labels">labels</a> |
|
| 16 | + | <a class="go small" href="{{ repo.url }}/milestones">milestones</a> |
|
| 17 | + | {% if shell.user.is_some() %}<a class="go small" href="{{ repo.url }}/issues/new">new issue</a>{% endif %} |
|
| 18 | + | </div> |
|
| 19 | + | {% if rows.is_empty() %}<p class="empty">No issues match.</p>{% else %} |
|
| 20 | + | <ul class="rows"> |
|
| 21 | + | {% for r in rows %} |
|
| 22 | + | <li> |
|
| 23 | + | <div> |
|
| 24 | + | <a class="name" href="{{ repo.url }}/issue/{{ r.issue.number }}">{% if r.issue.state == "open" %}<span class="sig sig-valid">●</span>{% else %}<span class="sig sig-unsigned">●</span>{% endif %} <span class="mute">#{{ r.issue.number }}</span> <strong>{{ r.issue.title }}</strong></a> |
|
| 25 | + | {% if r.issue.pinned %}<span class="tag">pinned</span>{% endif %}{% if r.issue.locked %}<span class="tag">locked</span>{% endif %} |
|
| 26 | + | {% for l in r.labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span>{% endfor %} |
|
| 27 | + | <p class="small">opened <time title="{{ r.issue.created_ms|when_i }}">{{ r.issue.created_ms|ago_i }}</time> by {{ r.issue.author }}{% if !r.assignees.is_empty() %} · assigned to {{ r.assignees.join(", ") }}{% endif %}{% if let Some(ms) = r.milestone %} · <a href="{{ repo.url }}/milestones">{{ ms }}</a>{% endif %}{% if r.comments > 0 %} · {{ r.comments|plural("comment", "comments") }}{% endif %}</p> |
|
| 28 | + | </div> |
|
| 29 | + | <div class="right small">updated <time title="{{ r.issue.updated_ms|when_i }}">{{ r.issue.updated_ms|ago_i }}</time></div> |
|
| 30 | + | </li> |
|
| 31 | + | {% endfor %} |
|
| 32 | + | </ul> |
|
| 33 | + | {% endif %} |
|
| 34 | + | {% endblock %} |
added templates/labels.html +21 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Labels · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "issues") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>Labels <small>{{ labels.len() }}</small></h1></div> |
|
| 8 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 9 | + | <div class="table"><table><tr><th>Label</th><th>Description</th><th></th></tr> |
|
| 10 | + | {% for l in labels %}<tr><td><span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span></td><td>{{ l.description }}</td><td>{% if can_triage %}<form class="tiny" method="post" action="{{ repo.url }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="name" value="{{ l.name }}"><button name="delete" value="1">delete</button></form>{% endif %}</td></tr>{% endfor %} |
|
| 11 | + | </table></div> |
|
| 12 | + | {% if can_triage %} |
|
| 13 | + | <form class="card" method="post" action="{{ repo.url }}/labels"> |
|
| 14 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 15 | + | <h2>Add or change a label</h2> |
|
| 16 | + | <div class="row"><div><label for="name">Name</label><input type="text" id="name" name="name" required></div><div><label for="colour">Colour</label><input type="color" id="colour" name="colour" value="#0072ff"></div></div> |
|
| 17 | + | <label for="description">Description</label><input type="text" id="description" name="description"> |
|
| 18 | + | <button type="submit">SAVE</button> |
|
| 19 | + | </form> |
|
| 20 | + | {% endif %} |
|
| 21 | + | {% endblock %} |
added templates/milestones.html +28 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Milestones · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "issues") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>Milestones <small>{{ rows.len() }}</small></h1></div> |
|
| 8 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 9 | + | {% if rows.is_empty() %}<p class="empty">No milestones.</p>{% else %} |
|
| 10 | + | <ul class="rows"> |
|
| 11 | + | {% for (ms, open, closed) in rows %} |
|
| 12 | + | <li><div><a class="name" href="{{ repo.url }}/issues?q=milestone:{{ ms.title }}"><strong>{{ ms.title }}</strong></a> {% if ms.state == "closed" %}<span class="tag">closed</span>{% endif %} |
|
| 13 | + | <p class="small">{{ ms.description }}{% if let Some(d) = ms.due_ms %} · due {{ d|when_i }}{% endif %} · {{ open }} open, {{ closed }} closed</p></div> |
|
| 14 | + | <div class="right">{% if can_triage %}<form class="tiny" method="post" action="{{ repo.url }}/milestones"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="title" value="{{ ms.title }}"><button name="state" value="{% if ms.state == "open" %}closed{% else %}open{% endif %}">{% if ms.state == "open" %}close{% else %}reopen{% endif %}</button></form>{% endif %}</div></li> |
|
| 15 | + | {% endfor %} |
|
| 16 | + | </ul> |
|
| 17 | + | {% endif %} |
|
| 18 | + | {% if can_triage %} |
|
| 19 | + | <form class="card" method="post" action="{{ repo.url }}/milestones"> |
|
| 20 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 21 | + | <h2>Add or change a milestone</h2> |
|
| 22 | + | <label for="title">Title</label><input type="text" id="title" name="title" required> |
|
| 23 | + | <label for="description">Description</label><input type="text" id="description" name="description"> |
|
| 24 | + | <label for="due">Due (YYYY-MM-DD, optional)</label><input type="text" id="due" name="due" pattern="\d{4}-\d{2}-\d{2}"> |
|
| 25 | + | <button type="submit">SAVE</button> |
|
| 26 | + | </form> |
|
| 27 | + | {% endif %} |
|
| 28 | + | {% endblock %} |
added templates/notifications.html +18 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Notifications{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Notifications <small>{{ unread }} unread</small></h1></div> |
|
| 5 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 6 | + | <div class="bar"> |
|
| 7 | + | <form method="post" action="/notifications/read"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">MARK ALL READ</button></form> |
|
| 8 | + | <form method="post" action="/settings/notifications"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label class="small"><input type="checkbox" name="email" value="1"{% if email_on %} checked{% endif %}> email me too</label> <button type="submit" class="small">SAVE</button></form> |
|
| 9 | + | </div> |
|
| 10 | + | {% if rows.is_empty() %}<p class="empty">Nothing yet. Watch a repository or take part in a thread and its events land here.</p>{% else %} |
|
| 11 | + | <ul class="rows"> |
|
| 12 | + | {% for n in rows %} |
|
| 13 | + | <li{% if !n.read %} class="unread"{% endif %}><div><a class="name" href="{{ n.url }}">{% if !n.read %}<span class="sig sig-valid">●</span> {% endif %}{{ n.title }}</a><p class="small">{{ n.kind }} · {{ n.actor }} · <time title="{{ n.at_ms|when_i }}">{{ n.at_ms|ago_i }}</time></p></div> |
|
| 14 | + | <div class="right">{% if !n.read %}<form class="tiny" method="post" action="/notifications/read"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="id" value="{{ n.id }}"><button>read</button></form>{% endif %}</div></li> |
|
| 15 | + | {% endfor %} |
|
| 16 | + | </ul> |
|
| 17 | + | {% endif %} |
|
| 18 | + | {% endblock %} |
added templates/search.html +9 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Search{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Search <small>{% if q.is_empty() %}repositories, issues, proposals, accounts{% else %}{{ hits.len() + repos.len() + accounts.len() }} results for “{{ q }}”{% endif %}</small></h1></div> |
|
| 5 | + | <form class="card wide" method="get" action="/search"><div class="row"><div><input type="search" name="q" value="{{ q }}" autofocus placeholder="words, or label:bug in a repository's issue list"></div><div><button type="submit">SEARCH</button></div></div></form> |
|
| 6 | + | {% if !repos.is_empty() %}<h2>Repositories</h2><ul class="rows">{% for r in repos %}<li><div><a class="name" href="/{{ r.owner }}/{{ r.name }}"><span class="mute">{{ r.owner }} /</span> <strong>{{ r.name }}</strong></a><p class="small">{{ r.description }}</p></div></li>{% endfor %}</ul>{% endif %} |
|
| 7 | + | {% if !accounts.is_empty() %}<h2>Accounts</h2><ul class="plain">{% for a in accounts %}<li><a href="/{{ a }}">{{ a }}</a></li>{% endfor %}</ul>{% endif %} |
|
| 8 | + | {% if !hits.is_empty() %}<h2>Issues and proposals</h2><ul class="rows">{% for h in hits %}<li><div><a class="name" href="{{ h.url }}"><span class="mute">{{ h.kind }} {{ h.slug }}#{{ h.number }}</span> <strong>{{ h.title }}</strong></a><p class="small">{{ h.snippet|safe }}</p></div></li>{% endfor %}</ul>{% endif %} |
|
| 9 | + | {% endblock %} |
modified Cargo.lock +87 −4
| … | 168 unchanged lines |
||
| 169 | 169 | "asn1-rs-derive", |
|
| 170 | 170 | "asn1-rs-impl", |
|
| 171 | 171 | "displaydoc", |
|
| 172 | - | "nom", |
|
| 172 | + | "nom 7.1.3", |
|
| 173 | 173 | "num-traits", |
|
| 174 | 174 | "rusticata-macros", |
|
| 175 | 175 | "thiserror 2.0.21", |
|
| … | 36 unchanged lines |
||
| 212 | 212 | ] |
|
| 213 | 213 | ||
| 214 | 214 | [[package]] |
|
| 215 | + | name = "async-trait" |
|
| 216 | + | version = "0.1.92" |
|
| 217 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 218 | + | checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" |
|
| 219 | + | dependencies = [ |
|
| 220 | + | "proc-macro2", |
|
| 221 | + | "quote", |
|
| 222 | + | "syn 3.0.6", |
|
| 223 | + | ] |
|
| 224 | + | ||
| 225 | + | [[package]] |
|
| 215 | 226 | name = "atoi" |
|
| 216 | 227 | version = "2.0.0" |
|
| 217 | 228 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 588 unchanged lines |
||
| 806 | 817 | dependencies = [ |
|
| 807 | 818 | "asn1-rs", |
|
| 808 | 819 | "displaydoc", |
|
| 809 | - | "nom", |
|
| 820 | + | "nom 7.1.3", |
|
| 810 | 821 | "num-bigint", |
|
| 811 | 822 | "num-traits", |
|
| 812 | 823 | "rusticata-macros", |
|
| … | 91 unchanged lines |
||
| 904 | 915 | ] |
|
| 905 | 916 | ||
| 906 | 917 | [[package]] |
|
| 918 | + | name = "email-encoding" |
|
| 919 | + | version = "0.4.2" |
|
| 920 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 921 | + | checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" |
|
| 922 | + | dependencies = [ |
|
| 923 | + | "base64 0.23.1", |
|
| 924 | + | "memchr", |
|
| 925 | + | ] |
|
| 926 | + | ||
| 927 | + | [[package]] |
|
| 928 | + | name = "email_address" |
|
| 929 | + | version = "0.2.9" |
|
| 930 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 931 | + | checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" |
|
| 932 | + | ||
| 933 | + | [[package]] |
|
| 907 | 934 | name = "encoding_rs" |
|
| 908 | 935 | version = "0.8.41" |
|
| 909 | 936 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 656 unchanged lines |
||
| 1566 | 1593 | ] |
|
| 1567 | 1594 | ||
| 1568 | 1595 | [[package]] |
|
| 1596 | + | name = "lettre" |
|
| 1597 | + | version = "0.11.23" |
|
| 1598 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1599 | + | checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" |
|
| 1600 | + | dependencies = [ |
|
| 1601 | + | "async-trait", |
|
| 1602 | + | "base64 0.23.1", |
|
| 1603 | + | "email-encoding", |
|
| 1604 | + | "email_address", |
|
| 1605 | + | "fastrand", |
|
| 1606 | + | "futures-io", |
|
| 1607 | + | "futures-util", |
|
| 1608 | + | "httpdate", |
|
| 1609 | + | "idna", |
|
| 1610 | + | "mime", |
|
| 1611 | + | "nom 8.0.0", |
|
| 1612 | + | "percent-encoding", |
|
| 1613 | + | "quoted_printable", |
|
| 1614 | + | "rustls", |
|
| 1615 | + | "socket2", |
|
| 1616 | + | "tokio", |
|
| 1617 | + | "tokio-rustls", |
|
| 1618 | + | "url", |
|
| 1619 | + | "webpki-roots 1.0.9", |
|
| 1620 | + | ] |
|
| 1621 | + | ||
| 1622 | + | [[package]] |
|
| 1569 | 1623 | name = "libc" |
|
| 1570 | 1624 | version = "0.2.189" |
|
| 1571 | 1625 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 236 unchanged lines |
||
| 1808 | 1862 | ] |
|
| 1809 | 1863 | ||
| 1810 | 1864 | [[package]] |
|
| 1865 | + | name = "nom" |
|
| 1866 | + | version = "8.0.0" |
|
| 1867 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1868 | + | checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" |
|
| 1869 | + | dependencies = [ |
|
| 1870 | + | "memchr", |
|
| 1871 | + | ] |
|
| 1872 | + | ||
| 1873 | + | [[package]] |
|
| 1811 | 1874 | name = "nu-ansi-term" |
|
| 1812 | 1875 | version = "0.50.3" |
|
| 1813 | 1876 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 133 unchanged lines |
||
| 1947 | 2010 | "hmac", |
|
| 1948 | 2011 | "http-body-util", |
|
| 1949 | 2012 | "hub-api", |
|
| 2013 | + | "lettre", |
|
| 1950 | 2014 | "oxy-core", |
|
| 1951 | 2015 | "oxy-server", |
|
| 1952 | 2016 | "pulldown-cmark", |
|
| 1953 | 2017 | "qrcode", |
|
| 1954 | 2018 | "rand 0.8.8", |
|
| 1955 | 2019 | "rcgen", |
|
| 2020 | + | "regex", |
|
| 1956 | 2021 | "reqwest", |
|
| 1957 | 2022 | "rustls", |
|
| 1958 | 2023 | "serde", |
|
| … | 318 unchanged lines |
||
| 2277 | 2342 | ] |
|
| 2278 | 2343 | ||
| 2279 | 2344 | [[package]] |
|
| 2345 | + | name = "quoted_printable" |
|
| 2346 | + | version = "0.5.2" |
|
| 2347 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2348 | + | checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" |
|
| 2349 | + | ||
| 2350 | + | [[package]] |
|
| 2280 | 2351 | name = "r-efi" |
|
| 2281 | 2352 | version = "6.0.0" |
|
| 2282 | 2353 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 88 unchanged lines |
||
| 2371 | 2442 | ] |
|
| 2372 | 2443 | ||
| 2373 | 2444 | [[package]] |
|
| 2445 | + | name = "regex" |
|
| 2446 | + | version = "1.13.1" |
|
| 2447 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2448 | + | checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" |
|
| 2449 | + | dependencies = [ |
|
| 2450 | + | "aho-corasick", |
|
| 2451 | + | "memchr", |
|
| 2452 | + | "regex-automata", |
|
| 2453 | + | "regex-syntax", |
|
| 2454 | + | ] |
|
| 2455 | + | ||
| 2456 | + | [[package]] |
|
| 2374 | 2457 | name = "regex-automata" |
|
| 2375 | 2458 | version = "0.4.18" |
|
| 2376 | 2459 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 103 unchanged lines |
||
| 2480 | 2563 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2481 | 2564 | checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" |
|
| 2482 | 2565 | dependencies = [ |
|
| 2483 | - | "nom", |
|
| 2566 | + | "nom 7.1.3", |
|
| 2484 | 2567 | ] |
|
| 2485 | 2568 | ||
| 2486 | 2569 | [[package]] |
|
| … | 1438 unchanged lines |
||
| 3925 | 4008 | "data-encoding", |
|
| 3926 | 4009 | "der-parser", |
|
| 3927 | 4010 | "lazy_static", |
|
| 3928 | - | "nom", |
|
| 4011 | + | "nom 7.1.3", |
|
| 3929 | 4012 | "oid-registry", |
|
| 3930 | 4013 | "ring", |
|
| 3931 | 4014 | "rusticata-macros", |
|
| … | 165 unchanged lines |
modified Cargo.toml +2 −0
| … | 44 unchanged lines |
||
| 45 | 45 | qrcode = { version = "0.14", default-features = false, features = ["svg"] } |
|
| 46 | 46 | subtle = "2" |
|
| 47 | 47 | blake3 = "1" |
|
| 48 | + | lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "smtp-transport", "builder"] } |
|
| 49 | + | regex = "1" |
|
| 48 | 50 | tempfile = "3" |
|
| 49 | 51 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } |
|
| 50 | 52 | ||
| … | 2 unchanged lines |
modified README.md +7 −4
| … | 8 unchanged lines |
||
| 9 | 9 | ||
| 10 | 10 | ## Status |
|
| 11 | 11 | ||
| 12 | - | Milestones 0.1 (browse), 0.2 (accounts and access) and 0.3 (proposals and |
|
| 13 | - | review) are done: pages show |
|
| 12 | + | Milestones 0.1 (browse), 0.2 (accounts and access), 0.3 (proposals and |
|
| 13 | + | review) and 0.4 (issues and notifications) are done: pages show |
|
| 14 | 14 | trees, files, blame, history with the dependency graph, changes with diffs, |
|
| 15 | 15 | comparisons, bookmarks, tags, conflicts, the trust policy and the push log; |
|
| 16 | 16 | accounts sign in with a password and optional TOTP, create repositories and |
|
| … | 1 unchanged lines |
||
| 18 | 18 | Oxygen keys so signed changes are attributed to them; proposals offer a |
|
| 19 | 19 | bookmark's changes for another with the resulting state, diff and conflicts |
|
| 20 | 20 | computed before landing, comments anchored to atoms, approvals backed by |
|
| 21 | - | seals, and landing from the hub or from `oxy proposal land`. Issues and the |
|
| 22 | - | rest follow the milestones in the specification. |
|
| 21 | + | seals, and landing from the hub or from `oxy proposal land`; issues with |
|
| 22 | + | labels, assignees, milestones, cross references and closing keywords; an |
|
| 23 | + | inbox with watching, subscriptions, mentions and optional email; search |
|
| 24 | + | over issues, proposals, repositories and accounts. The API, webhooks and |
|
| 25 | + | checks follow in 0.5. |
|
| 23 | 26 | ||
| 24 | 27 | ## Running |
|
| 25 | 28 | ||
| … | 32 unchanged lines |
modified crates/hub/Cargo.toml +2 −0
| … | 45 unchanged lines |
||
| 46 | 46 | qrcode.workspace = true |
|
| 47 | 47 | subtle.workspace = true |
|
| 48 | 48 | blake3.workspace = true |
|
| 49 | + | lettre.workspace = true |
|
| 50 | + | regex.workspace = true |
|
| 49 | 51 | ||
| 50 | 52 | [dev-dependencies] |
|
| 51 | 53 | tempfile.workspace = true |
|
| … | 3 unchanged lines |
modified crates/hub/src/auth/viewer.rs +4 −1
| … | 14 unchanged lines |
||
| 15 | 15 | /// The cookie value, which CSRF tokens are bound to. |
|
| 16 | 16 | pub session: String, |
|
| 17 | 17 | pub csrf: String, |
|
| 18 | + | /// Unread notifications, for the header. |
|
| 19 | + | pub unread: i64, |
|
| 18 | 20 | } |
|
| 19 | 21 | ||
| 20 | 22 | /// The signed-in account, if any. Never fails: anonymous is a valid viewer. |
|
| … | 44 unchanged lines |
||
| 65 | 67 | return None; |
|
| 66 | 68 | } |
|
| 67 | 69 | let csrf = csrf_token(&app.cookie_key, &expected); |
|
| 68 | - | Some(ViewerAccount { account, session: expected, csrf }) |
|
| 70 | + | let unread = db::notify::unread(&app.db, account.id).await.unwrap_or(0); |
|
| 71 | + | Some(ViewerAccount { account, session: expected, csrf, unread }) |
|
| 69 | 72 | } |
|
| 70 | 73 | ||
| 71 | 74 | impl FromRequestParts<Shared> for Viewer { |
|
| … | 15 unchanged lines |
modified crates/hub/src/config.rs +11 −0
| … | 15 unchanged lines |
||
| 16 | 16 | pub tls: Tls, |
|
| 17 | 17 | pub auth: Auth, |
|
| 18 | 18 | pub registration: Registration, |
|
| 19 | + | pub email: Email, |
|
| 19 | 20 | pub limits: Limits, |
|
| 20 | 21 | pub retention: Retention, |
|
| 21 | 22 | } |
|
| 22 | 23 | ||
| 24 | + | #[derive(Clone, Debug, Default, Deserialize)] |
|
| 25 | + | #[serde(default, deny_unknown_fields)] |
|
| 26 | + | pub struct Email { |
|
| 27 | + | /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email. |
|
| 28 | + | pub smtp: String, |
|
| 29 | + | /// `Oxygenhub <hub@example>`. |
|
| 30 | + | pub from: String, |
|
| 31 | + | } |
|
| 32 | + | ||
| 23 | 33 | #[derive(Clone, Debug, Deserialize)] |
|
| 24 | 34 | #[serde(default, deny_unknown_fields)] |
|
| 25 | 35 | pub struct Registration { |
|
| … | 139 unchanged lines |
||
| 165 | 175 | tls: Tls::default(), |
|
| 166 | 176 | auth: Auth::default(), |
|
| 167 | 177 | registration: Registration::default(), |
|
| 178 | + | email: Email::default(), |
|
| 168 | 179 | limits: Limits::default(), |
|
| 169 | 180 | retention: Retention::default(), |
|
| 170 | 181 | } |
|
| … | 125 unchanged lines |
modified crates/hub/src/db/accounts.rs +2 −0
| … | 18 unchanged lines |
||
| 19 | 19 | pub banned: bool, |
|
| 20 | 20 | pub created_ms: i64, |
|
| 21 | 21 | pub last_seen_ms: i64, |
|
| 22 | + | pub email_notifications: bool, |
|
| 23 | + | pub unsubscribe_key: String, |
|
| 22 | 24 | } |
|
| 23 | 25 | ||
| 24 | 26 | impl Account { |
|
| … | 547 unchanged lines |
modified crates/hub/src/db/mod.rs +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | //! migrations embedded and applied at start. One module per table group. |
|
| 3 | 3 | ||
| 4 | 4 | pub mod accounts; |
|
| 5 | + | pub mod issues; |
|
| 6 | + | pub mod notify; |
|
| 5 | 7 | pub mod proposals; |
|
| 6 | 8 | pub mod repos; |
|
| 7 | 9 | ||
| … | 24 unchanged lines |
modified crates/hub/src/lib.rs +5 −1
| … | 7 unchanged lines |
||
| 8 | 8 | pub mod config; |
|
| 9 | 9 | pub mod db; |
|
| 10 | 10 | pub mod keys; |
|
| 11 | + | pub mod notify; |
|
| 11 | 12 | pub mod protocol; |
|
| 12 | 13 | pub mod render; |
|
| 13 | 14 | pub mod repos; |
|
| … | 25 unchanged lines |
||
| 39 | 40 | let hub_key = keys::ensure_identity(&config.keys_dir(), &config.site.name, config.base_url())?; |
|
| 40 | 41 | let cookie_key = auth::CookieKey::load_or_create(&config.keys_dir())?; |
|
| 41 | 42 | let backend = protocol::HubBackend::new(repos.clone(), db.clone(), config.auth.push_tokens.clone(), tokio::runtime::Handle::current()); |
|
| 43 | + | let slot = backend.app_slot(); |
|
| 42 | 44 | let server = oxy_server::Server::new(backend); |
|
| 43 | - | Ok(Arc::new(App { config, db, repos, server, hub_key, cookie_key, started: std::time::Instant::now() })) |
|
| 45 | + | let app = Arc::new(App { config, db, repos, server, hub_key, cookie_key, started: std::time::Instant::now() }); |
|
| 46 | + | *slot.lock().unwrap() = Some(Arc::downgrade(&app)); |
|
| 47 | + | Ok(app) |
|
| 44 | 48 | } |
|
| 45 | 49 | ||
| 46 | 50 | /// The full router: pages, API, protocol and static assets. |
|
| … | 33 unchanged lines |
modified crates/hub/src/protocol.rs +25 −7
| … | 19 unchanged lines |
||
| 20 | 20 | /// Site tokens from `hub.toml`: full access, for bootstrapping. |
|
| 21 | 21 | site_tokens: Vec<String>, |
|
| 22 | 22 | runtime: tokio::runtime::Handle, |
|
| 23 | + | /// The application, set once it exists (the backend is built first). |
|
| 24 | + | app_hint: Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>>, |
|
| 23 | 25 | } |
|
| 24 | 26 | ||
| 25 | 27 | /// Who a request's credential resolved to. |
|
| … | 6 unchanged lines |
||
| 32 | 34 | ||
| 33 | 35 | impl HubBackend { |
|
| 34 | 36 | pub fn new(repos: Arc<Repos>, pool: db::Pool, site_tokens: Vec<String>, runtime: tokio::runtime::Handle) -> HubBackend { |
|
| 35 | - | HubBackend { repos, pool, site_tokens, runtime } |
|
| 37 | + | HubBackend { repos, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)) } |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// The slot the application registers itself in after construction. |
|
| 41 | + | pub fn app_slot(&self) -> Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>> { |
|
| 42 | + | self.app_hint.clone() |
|
| 36 | 43 | } |
|
| 37 | 44 | ||
| 38 | 45 | fn block<T>(&self, f: impl std::future::Future<Output = T>) -> T { |
|
| … | 113 unchanged lines |
||
| 152 | 159 | } |
|
| 153 | 160 | }) |
|
| 154 | 161 | .collect(); |
|
| 162 | + | // Messages of the changes stored, for `closes #N` (hub spec §8), when |
|
| 163 | + | // the default bookmark moved. |
|
| 164 | + | let messages: Vec<(String, String)> = if report.updated.iter().any(|u| u.name == row.default_bookmark) { |
|
| 165 | + | self.repos |
|
| 166 | + | .open(&parsed) |
|
| 167 | + | .map(|r| report.imported.iter().filter_map(|h| r.store().get_change(h).ok().map(|c| (h.to_hex(), c.message))).collect()) |
|
| 168 | + | .unwrap_or_default() |
|
| 169 | + | } else { |
|
| 170 | + | vec![] |
|
| 171 | + | }; |
|
| 172 | + | let app_hint = self.app_hint.clone(); |
|
| 155 | 173 | self.runtime.spawn(async move { |
|
| 156 | - | let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str())).filter(|(id, _)| *id > 0); |
|
| 157 | - | let label = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default(); |
|
| 158 | - | if let Err(e) = |
|
| 159 | - | db::repos::record_push(&pool, row.id, who.or(if label.is_empty() { None } else { Some((0, label.as_str())) }), &results) |
|
| 160 | - | .await |
|
| 161 | - | { |
|
| 174 | + | let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str())); |
|
| 175 | + | if let Err(e) = db::repos::record_push(&pool, row.id, who, &results).await { |
|
| 162 | 176 | tracing::warn!("recording push to {}: {e}", parsed); |
|
| 163 | 177 | } |
|
| 178 | + | let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()); |
|
| 179 | + | if let Some(app) = app { |
|
| 180 | + | crate::web::issues::close_from_messages(&app, &row, &messages).await; |
|
| 181 | + | } |
|
| 164 | 182 | }); |
|
| 165 | 183 | } |
|
| 166 | 184 | } |
|
| … | 26 unchanged lines |
modified crates/hub/src/render/mod.rs +1 −0
| … | 5 unchanged lines |
||
| 6 | 6 | pub mod graph; |
|
| 7 | 7 | pub mod highlight; |
|
| 8 | 8 | pub mod markdown; |
|
| 9 | + | pub mod refs; |
|
| 9 | 10 | pub mod time; |
|
| 10 | 11 | ||
| 11 | 12 | /// HTML-escape text for insertion into element content or attributes. |
|
| … | 43 unchanged lines |
modified crates/hub/src/web/mod.rs +24 −0
| … | 6 unchanged lines |
||
| 7 | 7 | pub mod filters; |
|
| 8 | 8 | mod history; |
|
| 9 | 9 | mod home; |
|
| 10 | + | mod inbox; |
|
| 11 | + | pub mod issues; |
|
| 10 | 12 | mod orgs; |
|
| 11 | 13 | pub mod proposals; |
|
| 12 | 14 | mod refs; |
|
| … | 47 unchanged lines |
||
| 60 | 62 | .route("/orgs/{name}/settings", get(orgs::settings).post(orgs::update)) |
|
| 61 | 63 | .route("/orgs/{name}/settings/members", post(orgs::member)) |
|
| 62 | 64 | .route("/orgs/{name}/settings/teams", post(orgs::team)) |
|
| 65 | + | .route("/notifications", get(inbox::inbox)) |
|
| 66 | + | .route("/notifications/read", post(inbox::mark_read)) |
|
| 67 | + | .route("/settings/notifications", get(inbox::unsubscribe).post(inbox::prefs)) |
|
| 68 | + | .route("/search", get(inbox::search)) |
|
| 63 | 69 | .route("/admin", get(admin::index)) |
|
| 64 | 70 | .route("/admin/registration", post(admin::set_mode)) |
|
| 65 | 71 | .route("/admin/invite", post(admin::invite)) |
|
| … | 26 unchanged lines |
||
| 92 | 98 | .route("/{owner}/{name}/proposal/{number}/edit", post(proposals::edit)) |
|
| 93 | 99 | .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state)) |
|
| 94 | 100 | .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land)) |
|
| 101 | + | .route("/{owner}/{name}/proposal/{number}/labels", post(proposals::set_labels)) |
|
| 102 | + | .route("/{owner}/{name}/watch", post(issues::watch)) |
|
| 103 | + | .route("/{owner}/{name}/issues", get(issues::list)) |
|
| 104 | + | .route("/{owner}/{name}/issues/new", get(issues::new_form).post(issues::create)) |
|
| 105 | + | .route("/{owner}/{name}/issue/{number}", get(issues::show)) |
|
| 106 | + | .route("/{owner}/{name}/issue/{number}/comment", post(issues::comment)) |
|
| 107 | + | .route("/{owner}/{name}/issue/{number}/comment/{id}/delete", post(issues::delete_comment)) |
|
| 108 | + | .route("/{owner}/{name}/issue/{number}/edit", post(issues::edit)) |
|
| 109 | + | .route("/{owner}/{name}/issue/{number}/state", post(issues::state)) |
|
| 110 | + | .route("/{owner}/{name}/issue/{number}/labels", post(issues::set_labels)) |
|
| 111 | + | .route("/{owner}/{name}/issue/{number}/assign", post(issues::assign)) |
|
| 112 | + | .route("/{owner}/{name}/issue/{number}/milestone", post(issues::set_milestone)) |
|
| 113 | + | .route("/{owner}/{name}/issue/{number}/subscribe", post(issues::subscribe)) |
|
| 114 | + | .route("/{owner}/{name}/labels", get(issues::labels).post(issues::save_label)) |
|
| 115 | + | .route("/{owner}/{name}/milestones", get(issues::milestones).post(issues::save_milestone)) |
|
| 95 | 116 | .route("/{owner}/{name}/oxy/v1/{endpoint}", any(crate::protocol::dispatch).layer(DefaultBodyLimit::max(push_limit))) |
|
| 96 | 117 | .fallback(not_found) |
|
| 97 | 118 | .layer(axum::middleware::from_fn_with_state(app.clone(), same_origin)) |
|
| … | 38 unchanged lines |
||
| 136 | 157 | pub user: Option<String>, |
|
| 137 | 158 | pub is_admin: bool, |
|
| 138 | 159 | pub csrf: String, |
|
| 160 | + | pub unread: i64, |
|
| 139 | 161 | } |
|
| 140 | 162 | ||
| 141 | 163 | impl Shell { |
|
| … | 6 unchanged lines |
||
| 148 | 170 | user: viewer.account().map(|a| a.username.clone()), |
|
| 149 | 171 | is_admin: viewer.is_admin(), |
|
| 150 | 172 | csrf: viewer.csrf().to_string(), |
|
| 173 | + | unread: viewer.0.as_ref().map(|v| v.unread).unwrap_or(0), |
|
| 151 | 174 | } |
|
| 152 | 175 | } |
|
| 153 | 176 | ||
| … | 135 unchanged lines |
||
| 289 | 312 | user: None, |
|
| 290 | 313 | is_admin: false, |
|
| 291 | 314 | csrf: String::new(), |
|
| 315 | + | unread: 0, |
|
| 292 | 316 | }); |
|
| 293 | 317 | let page = ErrorPage { shell, status: self.status.as_u16(), title: title.into(), detail: self.detail }; |
|
| 294 | 318 | match page.render() { |
|
| … | 87 unchanged lines |
modified crates/hub/src/web/proposals.rs +72 −2
| … | 240 unchanged lines |
||
| 241 | 241 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 242 | 242 | pdb::add_event(&app.db, created.id, &me.account.username, "opened", "").await.ok(); |
|
| 243 | 243 | accounts::audit(&app.db, Some(&me.account), "proposal.opened", &format!("{owner}/{name}#{}", created.number), &f.title).await.ok(); |
|
| 244 | - | Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()) |
|
| 244 | + | db::notify::index(&app.db, "proposal", row.id, created.id, created.number, &created.title, &created.body).await.ok(); |
|
| 245 | + | let url = format!("/{owner}/{name}/proposal/{}", created.number); |
|
| 246 | + | crate::notify::deliver( |
|
| 247 | + | &app, |
|
| 248 | + | crate::notify::Event { |
|
| 249 | + | kind: "proposal.opened", |
|
| 250 | + | title: format!("{owner}/{name}#{}: {}", created.number, created.title), |
|
| 251 | + | url: url.clone(), |
|
| 252 | + | actor: &me.account, |
|
| 253 | + | repo_id: row.id, |
|
| 254 | + | thread: Some(("proposal", created.id)), |
|
| 255 | + | mention_text: &f.body, |
|
| 256 | + | direct: vec![], |
|
| 257 | + | }, |
|
| 258 | + | ) |
|
| 259 | + | .await; |
|
| 260 | + | Ok(Redirect::to(&url).into_response()) |
|
| 245 | 261 | } |
|
| 246 | 262 | ||
| 247 | 263 | // ---------------------------------------------------------------- detail |
|
| … | 214 unchanged lines |
||
| 462 | 478 | state_hash: String, |
|
| 463 | 479 | source_label: String, |
|
| 464 | 480 | api_url: String, |
|
| 481 | + | labels: Vec<db::issues::Label>, |
|
| 482 | + | all_labels: Vec<db::issues::Label>, |
|
| 465 | 483 | } |
|
| 466 | 484 | ||
| 467 | 485 | #[derive(Deserialize)] |
|
| … | 74 unchanged lines |
||
| 542 | 560 | Ok(Page(ProposalPage { |
|
| 543 | 561 | shell, |
|
| 544 | 562 | repo: RepoShell::new(app, &row, "proposals").with_role(role), |
|
| 545 | - | body_html: render::markdown::render(&p.body, "/", "/"), |
|
| 563 | + | body_html: super::issues::markdown(app, row.id, &format!("/{owner}/{name}"), &p.body).await, |
|
| 546 | 564 | p, |
|
| 547 | 565 | tab, |
|
| 548 | 566 | changes, |
|
| … | 20 unchanged lines |
||
| 569 | 587 | state_hash: loaded.state, |
|
| 570 | 588 | source_label, |
|
| 571 | 589 | api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()), |
|
| 590 | + | labels: db::issues::proposal_labels(&app.db, loaded.row.id).await.unwrap_or_default(), |
|
| 591 | + | all_labels: db::issues::labels(&app.db, row.id).await.unwrap_or_default(), |
|
| 572 | 592 | })) |
|
| 573 | 593 | } |
|
| 574 | 594 | ||
| … | 270 unchanged lines |
||
| 845 | 865 | Ok(state) => { |
|
| 846 | 866 | pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 847 | 867 | pdb::add_event(&app.db, p.id, &me.account.username, "landed", "from the hub").await.ok(); |
|
| 868 | + | crate::notify::deliver( |
|
| 869 | + | &app, |
|
| 870 | + | crate::notify::Event { |
|
| 871 | + | kind: "proposal.landed", |
|
| 872 | + | title: format!("{owner}/{name}#{number}: {} landed", p.title), |
|
| 873 | + | url: format!("/{owner}/{name}/proposal/{number}"), |
|
| 874 | + | actor: &me.account, |
|
| 875 | + | repo_id: row.id, |
|
| 876 | + | thread: Some(("proposal", p.id)), |
|
| 877 | + | mention_text: "", |
|
| 878 | + | direct: p.author_id.into_iter().collect(), |
|
| 879 | + | }, |
|
| 880 | + | ) |
|
| 881 | + | .await; |
|
| 882 | + | if p.target == row.default_bookmark { |
|
| 883 | + | let messages: Vec<(String, String)> = |
|
| 884 | + | loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect(); |
|
| 885 | + | super::issues::close_from_messages(&app, &row, &messages).await; |
|
| 886 | + | } |
|
| 848 | 887 | accounts::audit(&app.db, Some(&me.account), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok(); |
|
| 849 | 888 | db::repos::record_push( |
|
| 850 | 889 | &app.db, |
|
| … | 17 unchanged lines |
||
| 868 | 907 | } |
|
| 869 | 908 | } |
|
| 870 | 909 | ||
| 910 | + | #[derive(Deserialize)] |
|
| 911 | + | pub struct LabelsForm { |
|
| 912 | + | #[serde(rename = "_csrf")] |
|
| 913 | + | csrf_token: String, |
|
| 914 | + | #[serde(default)] |
|
| 915 | + | label: Vec<i64>, |
|
| 916 | + | } |
|
| 917 | + | ||
| 918 | + | pub async fn set_labels( |
|
| 919 | + | State(app): State<Shared>, |
|
| 920 | + | viewer: Viewer, |
|
| 921 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 922 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>, |
|
| 923 | + | ) -> Result<Page<ProposalPage>, Error> { |
|
| 924 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 925 | + | require(&viewer, &shell)?; |
|
| 926 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 927 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 928 | + | if role < Role::Triage { |
|
| 929 | + | return Err(Error::not_found(shell)); |
|
| 930 | + | } |
|
| 931 | + | let p = pdb::get(&app.db, row.id, number) |
|
| 932 | + | .await |
|
| 933 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 934 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 935 | + | for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 936 | + | db::issues::set_proposal_label(&app.db, p.id, l.id, f.label.contains(&l.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 937 | + | } |
|
| 938 | + | detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await |
|
| 939 | + | } |
|
| 940 | + | ||
| 871 | 941 | // -------------------------------------------------------------------- API |
|
| 872 | 942 | ||
| 873 | 943 | pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal { |
|
| … | 65 unchanged lines |
modified crates/hub/src/web/repo.rs +6 −1
| … | 16 unchanged lines |
||
| 17 | 17 | repo: RepoShell, |
|
| 18 | 18 | o: view::Overview, |
|
| 19 | 19 | readme_html: Option<String>, |
|
| 20 | + | watching: bool, |
|
| 20 | 21 | } |
|
| 21 | 22 | ||
| 22 | 23 | pub async fn overview( |
|
| … | 8 unchanged lines |
||
| 31 | 32 | app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 32 | 33 | let rs = RepoShell::new(&app, &row, "code").with_role(role); |
|
| 33 | 34 | let readme_html = o.readme_html(&rs, &o.tree.target.label); |
|
| 34 | - | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html })) |
|
| 35 | + | let watching = match viewer.account() { |
|
| 36 | + | Some(a) => crate::db::notify::watching(&app.db, a.id, row.id).await.unwrap_or(false), |
|
| 37 | + | None => false, |
|
| 38 | + | }; |
|
| 39 | + | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching })) |
|
| 35 | 40 | } |
|
| 36 | 41 | ||
| 37 | 42 | impl view::Overview { |
|
| … | 197 unchanged lines |
modified docs/plan.md +12 −1
| … | 42 unchanged lines |
||
| 43 | 43 | - [ ] Notifications for review requests, mentions and landings (0.4). |
|
| 44 | 44 | - [ ] Hub-side landing under a policy through the delegate key (needs the attestation delegate rule in oxy, see spec §18). |
|
| 45 | 45 | ||
| 46 | + | ## 0.4 Issues and notifications (2026-09-27) |
|
| 47 | + | ||
| 48 | + | - [x] Issues sharing the number space with proposals (`/issue/N` redirects to a proposal of that number), comments with edit and delete, timeline events, close with a reason, reopen, lock, pin. |
|
| 49 | + | - [x] Labels (on issues and proposals), assignees, milestones with counts; list filters `label: assignee: milestone: state:` plus words; issue templates from `.oxyhub/ISSUE_TEMPLATE/*.md`. |
|
| 50 | + | - [x] Cross references: `#12`, `owner/name#12`, `@user` and change hashes become links outside code; `closes #N` in a change landed on the default bookmark closes the issue, from pushes and hub landings. |
|
| 51 | + | - [x] Notifications: inbox with unread count in the header, thread subscriptions (acting on a thread subscribes you), watching a repository, mentions, direct targets (authors, assignees); optional email through SMTP with a one-click unsubscribe. |
|
| 52 | + | - [x] Search: FTS5 over issues and proposals with highlighted snippets, plus repositories and accounts, limited to what the viewer may see. |
|
| 53 | + | - [x] Tests: `tests/issues.rs`. |
|
| 54 | + | - [ ] Email verification and password reset (needs the SMTP path proven on Earth). |
|
| 55 | + | - [ ] Import from GitHub and GitLab (1.x). |
|
| 56 | + | ||
| 46 | 57 | ## Later |
|
| 47 | 58 | ||
| 48 | - | - 0.4 issues and notifications · 0.5 API, webhooks, checks · 0.6 web edits, releases, mirror · 0.7 runner · 1.0 hardening. |
|
| 59 | + | - 0.5 API, webhooks, checks · 0.6 web edits, releases, mirror · 0.7 runner · 1.0 hardening. |
|
| 49 | 60 | ||
| 50 | 61 | ## Notes |
|
| 51 | 62 | ||
| … | 8 unchanged lines |
modified templates/base.html +2 −1
| … | 14 unchanged lines |
||
| 15 | 15 | <nav> |
|
| 16 | 16 | {% if let Some(u) = shell.user %} |
|
| 17 | 17 | <a class="go" href="/new" title="New repository">+ NEW</a> |
|
| 18 | + | <a class="bell" href="/notifications" title="Notifications">✉{% if shell.unread > 0 %}<span class="count">{{ shell.unread }}</span>{% endif %}</a> |
|
| 18 | 19 | <a class="me" href="/{{ u }}">{{ u }}</a> |
|
| 19 | 20 | <form class="inline" method="post" action="/logout"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>SIGN OUT</button></form> |
|
| 20 | 21 | {% else %} |
|
| … | 5 unchanged lines |
||
| 26 | 27 | <div class="band"> |
|
| 27 | 28 | <a href="/">Repositories</a> |
|
| 28 | 29 | <a href="/explore">Explore</a> |
|
| 29 | - | {% if shell.user.is_some() %}<a href="/settings">Settings</a>{% endif %} |
|
| 30 | + | {% if shell.user.is_some() %}<a href="/notifications">Notifications</a><a href="/settings">Settings</a>{% endif %} |
|
| 30 | 31 | <span class="grow"></span> |
|
| 31 | 32 | {% if shell.is_admin %}<a class="admin" href="/admin">Admin</a>{% endif %} |
|
| 32 | 33 | <a class="dim" href="/{{ shell.version }}" title="Oxygenhub {{ shell.version }}" tabindex="-1">v{{ shell.version }}</a> |
|
| … | 24 unchanged lines |
modified templates/overview.html +1 −0
| … | 21 unchanged lines |
||
| 22 | 22 | <aside class="side"> |
|
| 23 | 23 | <h3>About</h3> |
|
| 24 | 24 | {% if repo.description.is_empty() %}<p class="mute small">No description.</p>{% else %}<p>{{ repo.description }}</p>{% endif %} |
|
| 25 | + | {% if shell.user.is_some() %}<form method="post" action="{{ repo.url }}/watch"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if watching %}UNWATCH{% else %}WATCH{% endif %}</button></form>{% endif %} |
|
| 25 | 26 | <h3>Clone</h3> |
|
| 26 | 27 | <div class="clone"><code>oxy clone {{ repo.clone_url }}</code> <button class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div> |
|
| 27 | 28 | <h3>Bookmarks</h3> |
|
| … | 13 unchanged lines |
modified templates/proposal.html +3 −0
| … | 111 unchanged lines |
||
| 112 | 112 | {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %} |
|
| 113 | 113 | {% endif %} |
|
| 114 | 114 | {% endif %} |
|
| 115 | + | <h3>Labels</h3> |
|
| 116 | + | {% 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 %} |
|
| 117 | + | {% 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 %} |
|
| 115 | 118 | <h3>Approvals</h3> |
|
| 116 | 119 | {% if approvals.is_empty() %}<p class="mute small">None yet{% if requirements.required_approvals > 0 %}; {{ requirements.required_approvals }} sealed required{% endif %}.</p>{% else %} |
|
| 117 | 120 | <ul class="plain">{% for a in approvals %}<li>{% if a.sealed %}<span class="sig sig-valid">◈</span>{% else %}<span class="sig sig-unsigned">○</span>{% endif %} <a href="/{{ a.account }}">{{ a.account }}</a>{% if !a.sealed %} <span class="mute small">seal pending</span>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| … | 25 unchanged lines |
modified templates/repo_tabs.html +1 −0
| … | 4 unchanged lines |
||
| 5 | 5 | <a href="{{ repo.url }}"{% if tab == "code" %} class="on"{% endif %}>Code</a> |
|
| 6 | 6 | <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on"{% endif %}>Changes</a> |
|
| 7 | 7 | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals</a> |
|
| 8 | + | <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues</a> |
|
| 8 | 9 | <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on"{% endif %}>Bookmarks</a> |
|
| 9 | 10 | <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on"{% endif %}>Tags</a> |
|
| 10 | 11 | <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} class="on"{% endif %}>Conflicts</a> |
|
| … | 50 unchanged lines |
modified templates/style.css +12 −0
| … | 267 unchanged lines |
||
| 268 | 268 | details.linecomment summary:hover { color: var(--acc); } |
|
| 269 | 269 | details.linecomment form { font-family: "Barlow", sans-serif; background: var(--panel); border: 1px solid var(--acc); padding: 8px; margin: 4px 0; } |
|
| 270 | 270 | details.linecomment textarea { width: 100%; font: inherit; background: var(--bg); color: var(--ink); border: 1px solid var(--line); } |
|
| 271 | + | ||
| 272 | + | /* issues and notifications */ |
|
| 273 | + | .label { display: inline-block; font-size: 11px; padding: 1px 7px; margin-right: 4px; border: 1px solid var(--c, var(--acc)); color: var(--c, var(--acc)); vertical-align: middle; } |
|
| 274 | + | .timeline { margin: 0 0 8px 14px; } |
|
| 275 | + | details.inline-edit { display: inline; margin-left: 8px; } |
|
| 276 | + | details.inline-edit summary { cursor: pointer; color: var(--acc); } |
|
| 277 | + | details.inline-edit input, details.inline-edit textarea { width: 100%; font: inherit; background: var(--bg); color: var(--ink); border: 1px solid var(--line); margin: 4px 0; } |
|
| 278 | + | .tiny-form input[type=text], .tiny-form select { background: var(--bg); border: 1px solid var(--line); color: var(--ink); padding: 3px 6px; font: inherit; font-size: 13px; } |
|
| 279 | + | header nav .bell { color: var(--ink); position: relative; font-size: 16px; padding: 0 6px; } |
|
| 280 | + | header nav .bell .count { position: absolute; top: -6px; right: -4px; background: var(--acc); color: var(--now-ink); font-size: 10px; font-weight: 600; padding: 0 5px; border-radius: 9px; } |
|
| 281 | + | ul.rows li.unread { border-color: var(--acc); } |
|
| 282 | + | mark { background: color-mix(in srgb, var(--acc) 30%, transparent); color: inherit; } |