OXYGENHUB

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: &regex::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("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</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; }