OXYGENHUB

Milestone 0.3: proposals and review de505df5cf8d

Proposals from a bookmark, a fork or explicit changes; the projected state
with its diff and conflicts computed before landing and remembered;
comments anchored to atoms that follow lines and are marked outdated when
the line is gone; reviews whose approvals count once sealed with a linked
key; requirements (sealed approvals, code owners, conflicts, change
requests, drafts, stacking); landing from the hub without a policy or from
a client with oxy proposal land; JSON API for the oxy clients; an
end-to-end test.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 18:53 UTC
Signature
✔ signed by Andy McCall
Hash
de505df5cf8d527f25f81c64938d8e4ec9a6a631b9eefdf2c335abf7204f8046
Depends on
19bb31ff1f5c 9ff27da517fe d23464c0ac60
Depended on by
40a43921ef42 aeed63c00ad7 217814c239f0 668b2ad7495c d7e2c2754807 5a9a6e9a15bc dd0788b756f2 1000b40a2629 8bb7baeb4557 c43e4f631d99 8f0efa0729b0 09ffa7b600a9 03bfb945f5c1 5588bb3631d2 c7067c7bda56 3571c519ada6 33d98147511b 0ee5c3fb32eb 2abea30809dd 86a69b53aff0 2a8b621676b4 a7aa76ac52d7 119fd17d213f c08cec372c81 c92a99490cba c8684d44d549 d1bf2d698777 5d90e0e045e2 a2ef53c5db26 b5a0a69b3622 9a777fec4ed9 f7bcee49bf24 ffb67a81188c f4d6ef418a96 46b5e2c59c6b 6ae3fa0af4f6 d943160dafeb acc85ac0f70b 757195ad7a5d 71e65fcf95ab 16626c0d69c0 ac713df7b38b ed89fae5180b 3ea0b8af2d46 206c1fc1b06a 66fd9d6b1452 d0fbea2eb46e 7d2eb590051f 991b55638aaf 8933faff2043 9e50caedf4db 4f37afcc3583 8e96499f2705 6a1780558353 2291f8cc9c96 739a763a558c 6978fd76a066 cc1cd197df1d 1e23d0105921 0b20f1ec4007 a1b9869565aa 38833ed78707 9198231cee96 1f1eb06a9009 760a7dd30d6f 941abdd6bb4c ef20fdc14e8b 9756c67d3aec c79a1111dd56 e1ec9fbf538e 78001759604c 756fe44a0f4d 34beb1a7e376 3d1e74cdac76 64caed18236a 1ac84c62ba53 9df855f29948 3f02e5ea4f2d 3f4b0ea6eabf 145306044896 b64e2537d702 18e381ea88c2
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
35 atoms, 8 tree operations, 21 files

Browse the tree at this change

added crates/hub/migrations/0004_proposals.sql +73 −0
1 +
-- Proposals and review (hub spec §7). Approvals are seals in the object
2 +
-- store; rows here only record intent and discussion.
3 +
4 +
-- One number space per repository, shared by proposals and issues.
5 +
CREATE TABLE numbers (
6 +
    repo_id INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
7 +
    next    INTEGER NOT NULL DEFAULT 1
8 +
);
9 +
10 +
CREATE TABLE proposals (
11 +
    id               INTEGER PRIMARY KEY,
12 +
    repo_id          INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
13 +
    number           INTEGER NOT NULL,
14 +
    title            TEXT NOT NULL,
15 +
    body             TEXT NOT NULL DEFAULT '',
16 +
    author_id        INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
17 +
    author           TEXT NOT NULL DEFAULT '',
18 +
    state            TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('draft', 'open', 'landed', 'closed')),
19 +
    target           TEXT NOT NULL,
20 +
    source_kind      TEXT NOT NULL CHECK (source_kind IN ('bookmark', 'fork', 'changes')),
21 +
    source_repo_id   INTEGER REFERENCES repositories(id) ON DELETE SET NULL,
22 +
    source_bookmark  TEXT NOT NULL DEFAULT '',
23 +
    source_changes   TEXT NOT NULL DEFAULT '',
24 +
    projected_state  TEXT NOT NULL DEFAULT '',
25 +
    projected_heads  TEXT NOT NULL DEFAULT '',
26 +
    projected_target TEXT NOT NULL DEFAULT '',
27 +
    projected_changes TEXT NOT NULL DEFAULT '',
28 +
    conflicted       INTEGER NOT NULL DEFAULT 0,
29 +
    landed_state     TEXT NOT NULL DEFAULT '',
30 +
    created_ms       INTEGER NOT NULL,
31 +
    updated_ms       INTEGER NOT NULL,
32 +
    UNIQUE (repo_id, number)
33 +
);
34 +
35 +
CREATE TABLE proposal_comments (
36 +
    id          INTEGER PRIMARY KEY,
37 +
    proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
38 +
    author_id   INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
39 +
    author      TEXT NOT NULL DEFAULT '',
40 +
    body        TEXT NOT NULL,
41 +
    path        TEXT NOT NULL DEFAULT '',
42 +
    -- The atom the comment anchors to: the change that introduced the line and its index.
43 +
    anchor_change TEXT NOT NULL DEFAULT '',
44 +
    anchor_index  INTEGER NOT NULL DEFAULT 0,
45 +
    resolved    INTEGER NOT NULL DEFAULT 0,
46 +
    created_ms  INTEGER NOT NULL
47 +
);
48 +
CREATE INDEX proposal_comments_by_proposal ON proposal_comments (proposal_id, created_ms);
49 +
50 +
CREATE TABLE proposal_reviews (
51 +
    id          INTEGER PRIMARY KEY,
52 +
    proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
53 +
    author_id   INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
54 +
    author      TEXT NOT NULL DEFAULT '',
55 +
    verdict     TEXT NOT NULL CHECK (verdict IN ('comment', 'changes', 'approve')),
56 +
    body        TEXT NOT NULL DEFAULT '',
57 +
    created_ms  INTEGER NOT NULL
58 +
);
59 +
CREATE INDEX proposal_reviews_by_proposal ON proposal_reviews (proposal_id, created_ms);
60 +
61 +
CREATE TABLE proposal_events (
62 +
    id          INTEGER PRIMARY KEY,
63 +
    proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
64 +
    at_ms       INTEGER NOT NULL,
65 +
    actor       TEXT NOT NULL DEFAULT '',
66 +
    event       TEXT NOT NULL,
67 +
    detail      TEXT NOT NULL DEFAULT ''
68 +
);
69 +
70 +
-- Landing requirements per repository (hub spec §7.4).
71 +
ALTER TABLE repositories ADD COLUMN required_approvals INTEGER NOT NULL DEFAULT 0;
72 +
ALTER TABLE repositories ADD COLUMN require_owners INTEGER NOT NULL DEFAULT 0;
73 +
ALTER TABLE repositories ADD COLUMN allow_conflicts INTEGER NOT NULL DEFAULT 0;
added crates/hub/src/db/proposals.rs +298 −0
1 +
//! The `proposals`, `proposal_comments`, `proposal_reviews` and
2 +
//! `proposal_events` tables, and the per-repository number space.
3 +
4 +
use super::{now_ms, Pool};
5 +
use sqlx::FromRow;
6 +
7 +
#[derive(Clone, Debug, FromRow)]
8 +
pub struct ProposalRow {
9 +
    pub id: i64,
10 +
    pub repo_id: i64,
11 +
    pub number: i64,
12 +
    pub title: String,
13 +
    pub body: String,
14 +
    pub author_id: Option<i64>,
15 +
    pub author: String,
16 +
    pub state: String,
17 +
    pub target: String,
18 +
    pub source_kind: String,
19 +
    pub source_repo_id: Option<i64>,
20 +
    pub source_bookmark: String,
21 +
    pub source_changes: String,
22 +
    pub projected_state: String,
23 +
    pub projected_heads: String,
24 +
    pub projected_target: String,
25 +
    pub projected_changes: String,
26 +
    pub conflicted: bool,
27 +
    pub landed_state: String,
28 +
    pub created_ms: i64,
29 +
    pub updated_ms: i64,
30 +
}
31 +
32 +
impl ProposalRow {
33 +
    pub fn projected_heads(&self) -> Vec<String> {
34 +
        self.projected_heads.split(',').filter(|s| !s.is_empty()).map(String::from).collect()
35 +
    }
36 +
37 +
    pub fn projected_changes(&self) -> Vec<String> {
38 +
        self.projected_changes.split(',').filter(|s| !s.is_empty()).map(String::from).collect()
39 +
    }
40 +
41 +
    pub fn source_changes(&self) -> Vec<String> {
42 +
        self.source_changes.split(',').filter(|s| !s.is_empty()).map(String::from).collect()
43 +
    }
44 +
45 +
    pub fn is_open(&self) -> bool {
46 +
        self.state == "open" || self.state == "draft"
47 +
    }
48 +
}
49 +
50 +
#[derive(Clone, Debug, FromRow)]
51 +
pub struct CommentRow {
52 +
    pub id: i64,
53 +
    pub proposal_id: i64,
54 +
    pub author_id: Option<i64>,
55 +
    pub author: String,
56 +
    pub body: String,
57 +
    pub path: String,
58 +
    pub anchor_change: String,
59 +
    pub anchor_index: i64,
60 +
    pub resolved: bool,
61 +
    pub created_ms: i64,
62 +
}
63 +
64 +
#[derive(Clone, Debug, FromRow)]
65 +
pub struct ReviewRow {
66 +
    pub id: i64,
67 +
    pub proposal_id: i64,
68 +
    pub author_id: i64,
69 +
    pub author: String,
70 +
    pub verdict: String,
71 +
    pub body: String,
72 +
    pub created_ms: i64,
73 +
}
74 +
75 +
#[derive(Clone, Debug, FromRow)]
76 +
pub struct EventRow {
77 +
    pub id: i64,
78 +
    pub at_ms: i64,
79 +
    pub actor: String,
80 +
    pub event: String,
81 +
    pub detail: String,
82 +
}
83 +
84 +
/// Take the next number of a repository's shared number space.
85 +
pub async fn next_number(pool: &Pool, repo_id: i64) -> sqlx::Result<i64> {
86 +
    let mut tx = pool.begin().await?;
87 +
    sqlx::query("INSERT OR IGNORE INTO numbers (repo_id, next) VALUES (?, 1)").bind(repo_id).execute(&mut *tx).await?;
88 +
    let n: i64 = sqlx::query_scalar("SELECT next FROM numbers WHERE repo_id = ?").bind(repo_id).fetch_one(&mut *tx).await?;
89 +
    sqlx::query("UPDATE numbers SET next = next + 1 WHERE repo_id = ?").bind(repo_id).execute(&mut *tx).await?;
90 +
    tx.commit().await?;
91 +
    Ok(n)
92 +
}
93 +
94 +
pub struct NewProposal<'a> {
95 +
    pub repo_id: i64,
96 +
    pub title: &'a str,
97 +
    pub body: &'a str,
98 +
    pub author_id: Option<i64>,
99 +
    pub author: &'a str,
100 +
    pub draft: bool,
101 +
    pub target: &'a str,
102 +
    pub source_kind: &'a str,
103 +
    pub source_repo_id: Option<i64>,
104 +
    pub source_bookmark: &'a str,
105 +
    pub source_changes: &'a str,
106 +
}
107 +
108 +
pub async fn create(pool: &Pool, p: NewProposal<'_>) -> sqlx::Result<ProposalRow> {
109 +
    let number = next_number(pool, p.repo_id).await?;
110 +
    let now = now_ms();
111 +
    sqlx::query_as(
112 +
        "INSERT INTO proposals (repo_id, number, title, body, author_id, author, state, target, source_kind, source_repo_id, source_bookmark, source_changes, created_ms, updated_ms) \
113 +
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *",
114 +
    )
115 +
    .bind(p.repo_id)
116 +
    .bind(number)
117 +
    .bind(p.title)
118 +
    .bind(p.body)
119 +
    .bind(p.author_id)
120 +
    .bind(p.author)
121 +
    .bind(if p.draft { "draft" } else { "open" })
122 +
    .bind(p.target)
123 +
    .bind(p.source_kind)
124 +
    .bind(p.source_repo_id)
125 +
    .bind(p.source_bookmark)
126 +
    .bind(p.source_changes)
127 +
    .bind(now)
128 +
    .bind(now)
129 +
    .fetch_one(pool)
130 +
    .await
131 +
}
132 +
133 +
pub async fn get(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Option<ProposalRow>> {
134 +
    sqlx::query_as("SELECT * FROM proposals WHERE repo_id = ? AND number = ?").bind(repo_id).bind(number).fetch_optional(pool).await
135 +
}
136 +
137 +
pub async fn list(pool: &Pool, repo_id: i64, states: &[&str]) -> sqlx::Result<Vec<ProposalRow>> {
138 +
    let all: Vec<ProposalRow> =
139 +
        sqlx::query_as("SELECT * FROM proposals WHERE repo_id = ? ORDER BY number DESC").bind(repo_id).fetch_all(pool).await?;
140 +
    Ok(all.into_iter().filter(|p| states.is_empty() || states.contains(&p.state.as_str())).collect())
141 +
}
142 +
143 +
/// Open proposals of every repository, for the front page.
144 +
pub async fn open_for_author(pool: &Pool, author_id: i64) -> sqlx::Result<Vec<ProposalRow>> {
145 +
    sqlx::query_as("SELECT * FROM proposals WHERE author_id = ? AND state IN ('open', 'draft') ORDER BY updated_ms DESC")
146 +
        .bind(author_id)
147 +
        .fetch_all(pool)
148 +
        .await
149 +
}
150 +
151 +
pub async fn set_projection(
152 +
    pool: &Pool,
153 +
    id: i64,
154 +
    state: &str,
155 +
    heads: &str,
156 +
    target_state: &str,
157 +
    changes: &str,
158 +
    conflicted: bool,
159 +
) -> sqlx::Result<()> {
160 +
    sqlx::query("UPDATE proposals SET projected_state = ?, projected_heads = ?, projected_target = ?, projected_changes = ?, conflicted = ? WHERE id = ?")
161 +
        .bind(state)
162 +
        .bind(heads)
163 +
        .bind(target_state)
164 +
        .bind(changes)
165 +
        .bind(conflicted)
166 +
        .bind(id)
167 +
        .execute(pool)
168 +
        .await?;
169 +
    Ok(())
170 +
}
171 +
172 +
pub async fn set_state(pool: &Pool, id: i64, state: &str, landed_state: &str) -> sqlx::Result<()> {
173 +
    sqlx::query("UPDATE proposals SET state = ?, landed_state = ?, updated_ms = ? WHERE id = ?")
174 +
        .bind(state)
175 +
        .bind(landed_state)
176 +
        .bind(now_ms())
177 +
        .bind(id)
178 +
        .execute(pool)
179 +
        .await?;
180 +
    Ok(())
181 +
}
182 +
183 +
pub async fn edit(pool: &Pool, id: i64, title: &str, body: &str, target: &str) -> sqlx::Result<()> {
184 +
    sqlx::query("UPDATE proposals SET title = ?, body = ?, target = ?, updated_ms = ? WHERE id = ?")
185 +
        .bind(title)
186 +
        .bind(body)
187 +
        .bind(target)
188 +
        .bind(now_ms())
189 +
        .bind(id)
190 +
        .execute(pool)
191 +
        .await?;
192 +
    Ok(())
193 +
}
194 +
195 +
pub async fn touch(pool: &Pool, id: i64) -> sqlx::Result<()> {
196 +
    sqlx::query("UPDATE proposals SET updated_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?;
197 +
    Ok(())
198 +
}
199 +
200 +
pub async fn comments(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<CommentRow>> {
201 +
    sqlx::query_as("SELECT * FROM proposal_comments WHERE proposal_id = ? ORDER BY created_ms, id").bind(proposal_id).fetch_all(pool).await
202 +
}
203 +
204 +
pub async fn add_comment(
205 +
    pool: &Pool,
206 +
    proposal_id: i64,
207 +
    author_id: Option<i64>,
208 +
    author: &str,
209 +
    body: &str,
210 +
    path: &str,
211 +
    anchor: Option<(&str, i64)>,
212 +
) -> sqlx::Result<CommentRow> {
213 +
    let row = sqlx::query_as(
214 +
        "INSERT INTO proposal_comments (proposal_id, author_id, author, body, path, anchor_change, anchor_index, created_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *",
215 +
    )
216 +
    .bind(proposal_id)
217 +
    .bind(author_id)
218 +
    .bind(author)
219 +
    .bind(body)
220 +
    .bind(path)
221 +
    .bind(anchor.map(|a| a.0).unwrap_or(""))
222 +
    .bind(anchor.map(|a| a.1).unwrap_or(0))
223 +
    .bind(now_ms())
224 +
    .fetch_one(pool)
225 +
    .await?;
226 +
    touch(pool, proposal_id).await?;
227 +
    Ok(row)
228 +
}
229 +
230 +
pub async fn set_comment_resolved(pool: &Pool, id: i64, resolved: bool) -> sqlx::Result<()> {
231 +
    sqlx::query("UPDATE proposal_comments SET resolved = ? WHERE id = ?").bind(resolved).bind(id).execute(pool).await?;
232 +
    Ok(())
233 +
}
234 +
235 +
pub async fn reviews(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<ReviewRow>> {
236 +
    sqlx::query_as("SELECT * FROM proposal_reviews WHERE proposal_id = ? ORDER BY created_ms, id").bind(proposal_id).fetch_all(pool).await
237 +
}
238 +
239 +
pub async fn add_review(pool: &Pool, proposal_id: i64, author_id: i64, author: &str, verdict: &str, body: &str) -> sqlx::Result<ReviewRow> {
240 +
    let row = sqlx::query_as(
241 +
        "INSERT INTO proposal_reviews (proposal_id, author_id, author, verdict, body, created_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
242 +
    )
243 +
    .bind(proposal_id)
244 +
    .bind(author_id)
245 +
    .bind(author)
246 +
    .bind(verdict)
247 +
    .bind(body)
248 +
    .bind(now_ms())
249 +
    .fetch_one(pool)
250 +
    .await?;
251 +
    touch(pool, proposal_id).await?;
252 +
    Ok(row)
253 +
}
254 +
255 +
pub async fn events(pool: &Pool, proposal_id: i64) -> sqlx::Result<Vec<EventRow>> {
256 +
    sqlx::query_as("SELECT id, at_ms, actor, event, detail FROM proposal_events WHERE proposal_id = ? ORDER BY at_ms, id")
257 +
        .bind(proposal_id)
258 +
        .fetch_all(pool)
259 +
        .await
260 +
}
261 +
262 +
pub async fn add_event(pool: &Pool, proposal_id: i64, actor: &str, event: &str, detail: &str) -> sqlx::Result<()> {
263 +
    sqlx::query("INSERT INTO proposal_events (proposal_id, at_ms, actor, event, detail) VALUES (?, ?, ?, ?, ?)")
264 +
        .bind(proposal_id)
265 +
        .bind(now_ms())
266 +
        .bind(actor)
267 +
        .bind(event)
268 +
        .bind(detail)
269 +
        .execute(pool)
270 +
        .await?;
271 +
    Ok(())
272 +
}
273 +
274 +
/// Landing requirements of a repository.
275 +
#[derive(Clone, Copy, Debug, Default, FromRow)]
276 +
pub struct Requirements {
277 +
    pub required_approvals: i64,
278 +
    pub require_owners: bool,
279 +
    pub allow_conflicts: bool,
280 +
}
281 +
282 +
pub async fn requirements(pool: &Pool, repo_id: i64) -> sqlx::Result<Requirements> {
283 +
    sqlx::query_as("SELECT required_approvals, require_owners, allow_conflicts FROM repositories WHERE id = ?")
284 +
        .bind(repo_id)
285 +
        .fetch_one(pool)
286 +
        .await
287 +
}
288 +
289 +
pub async fn set_requirements(pool: &Pool, repo_id: i64, r: Requirements) -> sqlx::Result<()> {
290 +
    sqlx::query("UPDATE repositories SET required_approvals = ?, require_owners = ?, allow_conflicts = ? WHERE id = ?")
291 +
        .bind(r.required_approvals)
292 +
        .bind(r.require_owners)
293 +
        .bind(r.allow_conflicts)
294 +
        .bind(repo_id)
295 +
        .execute(pool)
296 +
        .await?;
297 +
    Ok(())
298 +
}
added crates/hub/src/repos/proposals.rs +209 −0
1 +
//! Proposals (hub spec §7): the projected state, its diff and conflicts,
2 +
//! atom anchors for comments, seal-backed approvals, code owners and the
3 +
//! landing requirements. Pure functions over `oxy-core`, run under the
4 +
//! repository lock.
5 +
6 +
use super::cache::{Loaded, StateCache};
7 +
use super::view::{self, ChangeView, ConflictView, RefTarget};
8 +
use super::RepoError;
9 +
use oxy_core::hash::{ChangeHash, KeyId, ObjectRef};
10 +
use oxy_core::objects::StateObject;
11 +
use oxy_core::repo::{FileDiff, Repo};
12 +
use oxy_core::state::FileContent;
13 +
use std::collections::{BTreeMap, BTreeSet, HashMap};
14 +
use std::sync::Arc;
15 +
16 +
type Result<T> = std::result::Result<T, RepoError>;
17 +
18 +
/// Where a proposal's changes come from, resolved.
19 +
pub enum Source<'a> {
20 +
    /// Heads of a bookmark of this repository.
21 +
    Bookmark(Vec<ChangeHash>),
22 +
    /// A fork: its repository directory and the bookmark's heads there.
23 +
    Fork {
24 +
        fork: &'a Repo,
25 +
        heads: Vec<ChangeHash>,
26 +
    },
27 +
    Changes(Vec<ChangeHash>),
28 +
}
29 +
30 +
/// The projected state of a proposal and what the pages show of it.
31 +
pub struct Projection {
32 +
    pub target_heads: Vec<ChangeHash>,
33 +
    pub target_state: ObjectRef,
34 +
    pub heads: Vec<ChangeHash>,
35 +
    pub state: ObjectRef,
36 +
    /// The changes the proposal adds, dependency order.
37 +
    pub changes: Vec<ChangeView>,
38 +
    pub hashes: Vec<ChangeHash>,
39 +
    pub conflicted: bool,
40 +
    pub conflicts: Vec<ConflictView>,
41 +
    pub files: Vec<FileDiff>,
42 +
    pub bases: BTreeMap<String, Vec<u8>>,
43 +
    pub loaded: Arc<Loaded>,
44 +
}
45 +
46 +
/// Compute the projection: the union of the target's heads and the
47 +
/// source, with the source's changes brought into this store when they
48 +
/// come from a fork.
49 +
pub fn project(repo: &Repo, cache: &StateCache, target: &str, source: Source<'_>) -> Result<Projection> {
50 +
    let target_heads = repo.bookmarks()?.into_iter().find(|b| b.name == target).map(|b| b.heads).unwrap_or_default();
51 +
    let candidate: Vec<ChangeHash> = match &source {
52 +
        Source::Bookmark(heads) => heads.clone(),
53 +
        Source::Changes(hashes) => hashes.clone(),
54 +
        Source::Fork { fork, heads } => {
55 +
            // Objects are content addressed: copy what is missing straight from the fork's store.
56 +
            let fork_state = fork.load_state(heads)?;
57 +
            let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !repo.store().has(h)).collect();
58 +
            if !missing.is_empty() {
59 +
                let bundle = fork.bundle_for(&missing)?;
60 +
                repo.import_bundle(&bundle, repo.current_policy()?.is_some())?;
61 +
            }
62 +
            heads.clone()
63 +
        }
64 +
    };
65 +
    for h in &candidate {
66 +
        if !repo.store().has(h) {
67 +
            return Err(RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!("change {} is not in this repository", h.short(12)))));
68 +
        }
69 +
    }
70 +
    let mut union: Vec<ChangeHash> = target_heads.clone();
71 +
    union.extend(candidate.iter().copied());
72 +
    let loaded = cache.load(repo, &union)?;
73 +
    let heads = loaded.state.heads();
74 +
    let target_loaded = cache.load(repo, &target_heads)?;
75 +
    let labels = view::head_labels(repo)?;
76 +
    let head_set: BTreeSet<ChangeHash> = heads.iter().copied().collect();
77 +
    let mut added: Vec<(ChangeHash, oxy_core::state::ChangeMeta)> =
78 +
        loaded.state.changes().filter(|(h, _)| !target_loaded.state.contains(h)).map(|(h, m)| (*h, m.clone())).collect();
79 +
    // Dependency order, oldest first: a change after everything it depends on.
80 +
    added.sort_by(|a, b| a.1.hlc.cmp(&b.1.hlc).then(a.0.cmp(&b.0)));
81 +
    let hashes: Vec<ChangeHash> = added.iter().map(|(h, _)| *h).collect();
82 +
    let changes: Vec<ChangeView> = added.iter().map(|(h, m)| view::change_view_pub(repo, h, m, head_set.contains(h), &labels)).collect();
83 +
    let conflicted = loaded.snapshot.is_conflicted();
84 +
    let conflicts = if conflicted { view::conflicts_in(repo, &loaded, "projected") } else { vec![] };
85 +
    let (files, bases) = crate::render::diff::snapshot_diff(&target_loaded.snapshot, &loaded.snapshot);
86 +
    let state = StateObject::new(heads.clone()).hash();
87 +
    let target_state = StateObject::new(target_heads.clone()).hash();
88 +
    Ok(Projection { target_heads, target_state, heads, state, changes, hashes, conflicted, conflicts, files, bases, loaded })
89 +
}
90 +
91 +
/// The atom at line `new_no` (1-based) of `path` in the projected state.
92 +
pub fn atom_at(loaded: &Loaded, path: &str, new_no: usize) -> Option<(ChangeHash, u32)> {
93 +
    let f = loaded.snapshot.files.get(path)?;
94 +
    if !matches!(f.content, FileContent::Text { .. }) {
95 +
        return None;
96 +
    }
97 +
    let lines = loaded.state.live_lines(&f.node);
98 +
    lines.get(new_no.checked_sub(1)?).map(|((h, i), _)| (*h, *i))
99 +
}
100 +
101 +
/// The current line (1-based) of an atom in `path`, if it is still live.
102 +
pub fn line_of(loaded: &Loaded, path: &str, atom: (ChangeHash, u32)) -> Option<usize> {
103 +
    let f = loaded.snapshot.files.get(path)?;
104 +
    let lines = loaded.state.live_lines(&f.node);
105 +
    lines.iter().position(|((h, i), _)| (*h, *i) == atom).map(|p| p + 1)
106 +
}
107 +
108 +
/// Keys that have sealed every one of `hashes` with a valid seal.
109 +
pub fn sealing_keys(repo: &Repo, hashes: &[ChangeHash]) -> Result<Vec<KeyId>> {
110 +
    if hashes.is_empty() {
111 +
        return Ok(vec![]);
112 +
    }
113 +
    let mut counts: HashMap<KeyId, usize> = HashMap::new();
114 +
    for h in hashes {
115 +
        let mut keys_here: BTreeSet<KeyId> = BTreeSet::new();
116 +
        for seal_ref in repo.store().seals_for(h)? {
117 +
            let attest = repo.store().get_attest(&seal_ref)?;
118 +
            let oxy_core::attest::AttestBody::Seal { hashes: covered } = &attest.body else { continue };
119 +
            if !covered.contains(h) {
120 +
                continue;
121 +
            }
122 +
            for sig in repo.store().signatures(&seal_ref)? {
123 +
                if let Ok(id) = repo.store().get_identity(&sig.key) {
124 +
                    if oxy_core::identity::verify(&id, &sig, oxy_core::identity::Domain::Attest, &seal_ref).is_ok() {
125 +
                        keys_here.insert(sig.key);
126 +
                    }
127 +
                }
128 +
            }
129 +
        }
130 +
        for k in keys_here {
131 +
            *counts.entry(k).or_default() += 1;
132 +
        }
133 +
    }
134 +
    Ok(counts.into_iter().filter(|(_, n)| *n == hashes.len()).map(|(k, _)| k).collect())
135 +
}
136 +
137 +
/// `.oxyowners` in the target state (CODEOWNERS syntax): the owners of
138 +
/// every path the proposal touches.
139 +
pub fn owners_for(target: &Loaded, touched: &[String]) -> Vec<String> {
140 +
    let Some(f) = target.snapshot.files.get(".oxyowners") else { return vec![] };
141 +
    let FileContent::Text { bytes, .. } = &f.content else { return vec![] };
142 +
    let text = String::from_utf8_lossy(bytes);
143 +
    let rules: Vec<(String, Vec<String>)> = text
144 +
        .lines()
145 +
        .map(str::trim)
146 +
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
147 +
        .filter_map(|l| {
148 +
            let mut parts = l.split_whitespace();
149 +
            let pattern = parts.next()?.to_string();
150 +
            let owners: Vec<String> = parts.map(|o| o.trim_start_matches('@').to_string()).collect();
151 +
            Some((pattern, owners))
152 +
        })
153 +
        .collect();
154 +
    let mut out: BTreeSet<String> = BTreeSet::new();
155 +
    for path in touched {
156 +
        // The last matching rule wins, as in CODEOWNERS.
157 +
        if let Some((_, owners)) = rules.iter().rev().find(|(pattern, _)| owner_pattern_matches(pattern, path)) {
158 +
            out.extend(owners.iter().cloned());
159 +
        }
160 +
    }
161 +
    out.into_iter().collect()
162 +
}
163 +
164 +
fn owner_pattern_matches(pattern: &str, path: &str) -> bool {
165 +
    let p = pattern.trim_start_matches('/');
166 +
    if p == "*" {
167 +
        return true;
168 +
    }
169 +
    if let Some(dir) = p.strip_suffix('/') {
170 +
        return path.starts_with(&format!("{dir}/"));
171 +
    }
172 +
    if let Some(ext) = p.strip_prefix("*.") {
173 +
        return path.ends_with(&format!(".{ext}"));
174 +
    }
175 +
    if let Some(prefix) = p.strip_suffix("/*") {
176 +
        return path.strip_prefix(&format!("{prefix}/")).is_some_and(|rest| !rest.contains('/'));
177 +
    }
178 +
    if let Some(prefix) = p.strip_suffix("/**") {
179 +
        return path.starts_with(&format!("{prefix}/"));
180 +
    }
181 +
    path == p || path.starts_with(&format!("{p}/"))
182 +
}
183 +
184 +
/// The union of two head lists as a state object reference, for landing.
185 +
pub fn state_ref(heads: &[ChangeHash]) -> ObjectRef {
186 +
    StateObject::new(heads.to_vec()).hash()
187 +
}
188 +
189 +
/// A proposal's changes as a `RefTarget`, for browsing its tree.
190 +
pub fn target_of(label: &str, heads: &[ChangeHash]) -> RefTarget {
191 +
    RefTarget { label: label.into(), kind: view::RefKind::Change, heads: heads.to_vec() }
192 +
}
193 +
194 +
#[cfg(test)]
195 +
mod tests {
196 +
    use super::*;
197 +
198 +
    #[test]
199 +
    fn owner_patterns() {
200 +
        assert!(owner_pattern_matches("*", "any/thing"));
201 +
        assert!(owner_pattern_matches("docs/", "docs/spec.md"));
202 +
        assert!(!owner_pattern_matches("docs/", "src/docs.rs"));
203 +
        assert!(owner_pattern_matches("*.rs", "src/main.rs"));
204 +
        assert!(owner_pattern_matches("src/*", "src/main.rs"));
205 +
        assert!(!owner_pattern_matches("src/*", "src/a/b.rs"));
206 +
        assert!(owner_pattern_matches("crates/**", "crates/hub/src/lib.rs"));
207 +
        assert!(owner_pattern_matches("Cargo.toml", "Cargo.toml"));
208 +
    }
209 +
}
added crates/hub/src/web/proposals.rs +938 −0
1 +
//! Proposal pages (hub spec §7): list, new, detail with conversation,
2 +
//! changes and files, comments anchored to atoms, reviews, landing.
3 +
4 +
use super::history::{render_files, FileRows};
5 +
use super::settings::{csrf, require};
6 +
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
7 +
use crate::auth::{Access, Role, Viewer};
8 +
use crate::db::{self, accounts, proposals as pdb};
9 +
use crate::render::{self, diff};
10 +
use crate::repos::proposals::{self as pl, Projection, Source};
11 +
use crate::repos::view::{self, ChangeView, ConflictView};
12 +
use crate::repos::RepoName;
13 +
use crate::Shared;
14 +
use askama::Template;
15 +
use axum::extract::{Form, Path, Query, State};
16 +
use axum::response::{IntoResponse, Redirect, Response};
17 +
use hub_api::{Approval, ProposalSource, ProposalState};
18 +
use oxy_core::hash::ChangeHash;
19 +
use serde::Deserialize;
20 +
use std::collections::{BTreeMap, HashMap};
21 +
22 +
// ------------------------------------------------------------------ list
23 +
24 +
#[derive(Template)]
25 +
#[template(path = "proposals.html")]
26 +
pub struct ProposalsPage {
27 +
    shell: Shell,
28 +
    repo: RepoShell,
29 +
    rows: Vec<pdb::ProposalRow>,
30 +
    filter: String,
31 +
    counts: (usize, usize, usize),
32 +
    bookmarks: Vec<String>,
33 +
}
34 +
35 +
#[derive(Deserialize)]
36 +
pub struct ListQuery {
37 +
    state: Option<String>,
38 +
}
39 +
40 +
pub async fn list(
41 +
    State(app): State<Shared>,
42 +
    viewer: Viewer,
43 +
    Path((owner, name)): Path<(String, String)>,
44 +
    Query(q): Query<ListQuery>,
45 +
) -> Result<Page<ProposalsPage>, Error> {
46 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals"), &viewer);
47 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
48 +
    let all = pdb::list(&app.db, row.id, &[]).await.map_err(|e| Error::from_db(shell.clone(), e))?;
49 +
    let counts = (
50 +
        all.iter().filter(|p| p.is_open()).count(),
51 +
        all.iter().filter(|p| p.state == "landed").count(),
52 +
        all.iter().filter(|p| p.state == "closed").count(),
53 +
    );
54 +
    let filter = q.state.unwrap_or_else(|| "open".into());
55 +
    let rows = all
56 +
        .into_iter()
57 +
        .filter(|p| match filter.as_str() {
58 +
            "open" => p.is_open(),
59 +
            "landed" => p.state == "landed",
60 +
            "closed" => p.state == "closed",
61 +
            _ => true,
62 +
        })
63 +
        .collect();
64 +
    let bookmarks = app
65 +
        .repos
66 +
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
67 +
        .await
68 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
69 +
    Ok(Page(ProposalsPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), rows, filter, counts, bookmarks }))
70 +
}
71 +
72 +
// ------------------------------------------------------------------- new
73 +
74 +
#[derive(Template)]
75 +
#[template(path = "proposal_new.html")]
76 +
pub struct NewProposalPage {
77 +
    shell: Shell,
78 +
    repo: RepoShell,
79 +
    bookmarks: Vec<String>,
80 +
    source: String,
81 +
    target: String,
82 +
    error: Option<String>,
83 +
    /// Preview: the changes the proposal would add.
84 +
    preview: Vec<ChangeView>,
85 +
    conflicted: bool,
86 +
}
87 +
88 +
#[derive(Deserialize)]
89 +
pub struct NewQuery {
90 +
    source: Option<String>,
91 +
    target: Option<String>,
92 +
}
93 +
94 +
/// `owner/name:bookmark` for a fork, else a bookmark of this repository.
95 +
fn parse_source(app: &Shared, repo: &RepoName, source: &str) -> Option<(Option<RepoName>, String)> {
96 +
    if let Some((slug, bookmark)) = source.split_once(':') {
97 +
        let fork = RepoName::parse(slug)?;
98 +
        if !app.repos.exists(&fork) || fork == *repo {
99 +
            return None;
100 +
        }
101 +
        return Some((Some(fork), bookmark.to_string()));
102 +
    }
103 +
    crate::repos::names::valid_segment(source).then(|| (None, source.to_string()))
104 +
}
105 +
106 +
async fn preview_for(
107 +
    app: &Shared,
108 +
    repo: &RepoName,
109 +
    target: &str,
110 +
    source: &str,
111 +
) -> Result<(Vec<ChangeView>, bool), crate::repos::RepoError> {
112 +
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], false)) };
113 +
    let target = target.to_string();
114 +
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
115 +
    app.repos
116 +
        .read(repo, move |r, cache| {
117 +
            let projection = match &fork_dir {
118 +
                Some(dir) => {
119 +
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
120 +
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
121 +
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
122 +
                }
123 +
                None => {
124 +
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
125 +
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
126 +
                }
127 +
            };
128 +
            Ok((projection.changes, projection.conflicted))
129 +
        })
130 +
        .await
131 +
}
132 +
133 +
pub async fn new_form(
134 +
    State(app): State<Shared>,
135 +
    viewer: Viewer,
136 +
    Path((owner, name)): Path<(String, String)>,
137 +
    Query(q): Query<NewQuery>,
138 +
) -> Result<Page<NewProposalPage>, Error> {
139 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
140 +
    require(&viewer, &shell)?;
141 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
142 +
    let bookmarks = app
143 +
        .repos
144 +
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
145 +
        .await
146 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
147 +
    let target = q.target.unwrap_or_else(|| row.default_bookmark.clone());
148 +
    let source = q.source.unwrap_or_default();
149 +
    let (preview, conflicted) =
150 +
        if source.is_empty() { (vec![], false) } else { preview_for(&app, &repo, &target, &source).await.unwrap_or((vec![], false)) };
151 +
    Ok(Page(NewProposalPage {
152 +
        shell,
153 +
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
154 +
        bookmarks,
155 +
        source,
156 +
        target,
157 +
        error: None,
158 +
        preview,
159 +
        conflicted,
160 +
    }))
161 +
}
162 +
163 +
#[derive(Deserialize)]
164 +
pub struct NewForm {
165 +
    #[serde(rename = "_csrf")]
166 +
    csrf_token: String,
167 +
    title: String,
168 +
    #[serde(default)]
169 +
    body: String,
170 +
    target: String,
171 +
    source: String,
172 +
    #[serde(default)]
173 +
    draft: Option<String>,
174 +
}
175 +
176 +
pub async fn create(
177 +
    State(app): State<Shared>,
178 +
    viewer: Viewer,
179 +
    Path((owner, name)): Path<(String, String)>,
180 +
    Form(f): Form<NewForm>,
181 +
) -> Result<Response, Error> {
182 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
183 +
    let me = require(&viewer, &shell)?;
184 +
    csrf(&viewer, &f.csrf_token, &shell)?;
185 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
186 +
    let bookmarks = app
187 +
        .repos
188 +
        .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>()))
189 +
        .await
190 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
191 +
    let fail = |msg: &str, preview: Vec<ChangeView>| {
192 +
        Page(NewProposalPage {
193 +
            shell: shell.clone(),
194 +
            repo: RepoShell::new(&app, &row, "proposals").with_role(role),
195 +
            bookmarks: bookmarks.clone(),
196 +
            source: f.source.clone(),
197 +
            target: f.target.clone(),
198 +
            error: Some(msg.into()),
199 +
            preview,
200 +
            conflicted: false,
201 +
        })
202 +
        .into_response()
203 +
    };
204 +
    if f.title.trim().is_empty() {
205 +
        return Ok(fail("Give the proposal a title.", vec![]));
206 +
    }
207 +
    if !bookmarks.contains(&f.target) {
208 +
        return Ok(fail("Pick a target bookmark of this repository.", vec![]));
209 +
    }
210 +
    let Some((fork, bookmark)) = parse_source(&app, &repo, f.source.trim()) else {
211 +
        return Ok(fail("The source is a bookmark of this repository, or owner/name:bookmark of a fork.", vec![]));
212 +
    };
213 +
    if fork.is_none() && bookmark == f.target {
214 +
        return Ok(fail("The source and the target are the same bookmark.", vec![]));
215 +
    }
216 +
    let (preview, _) = preview_for(&app, &repo, &f.target, f.source.trim()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
217 +
    if preview.is_empty() {
218 +
        return Ok(fail("The source adds nothing to the target.", vec![]));
219 +
    }
220 +
    let source_repo_id = match &fork {
221 +
        Some(fk) => db::repos::get(&app.db, &fk.owner, &fk.name).await.map_err(|e| Error::from_db(shell.clone(), e))?.map(|r| r.id),
222 +
        None => None,
223 +
    };
224 +
    let created = pdb::create(
225 +
        &app.db,
226 +
        pdb::NewProposal {
227 +
            repo_id: row.id,
228 +
            title: f.title.trim(),
229 +
            body: f.body.trim(),
230 +
            author_id: Some(me.account.id),
231 +
            author: &me.account.username,
232 +
            draft: f.draft.is_some(),
233 +
            target: &f.target,
234 +
            source_kind: if fork.is_some() { "fork" } else { "bookmark" },
235 +
            source_repo_id,
236 +
            source_bookmark: &bookmark,
237 +
            source_changes: "",
238 +
        },
239 +
    )
240 +
    .await
241 +
    .map_err(|e| Error::from_db(shell.clone(), e))?;
242 +
    pdb::add_event(&app.db, created.id, &me.account.username, "opened", "").await.ok();
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())
245 +
}
246 +
247 +
// ---------------------------------------------------------------- detail
248 +
249 +
/// A comment placed on a diff row, or general.
250 +
pub struct CommentView {
251 +
    pub row: pdb::CommentRow,
252 +
    pub body_html: String,
253 +
    pub line: Option<usize>,
254 +
}
255 +
256 +
pub struct ReviewView {
257 +
    pub row: pdb::ReviewRow,
258 +
    pub body_html: String,
259 +
    pub sealed: bool,
260 +
}
261 +
262 +
/// Everything the detail page and the API need, computed once under the lock.
263 +
pub struct Loaded {
264 +
    pub row: pdb::ProposalRow,
265 +
    pub changes: Vec<ChangeView>,
266 +
    pub hashes: Vec<ChangeHash>,
267 +
    pub heads: Vec<ChangeHash>,
268 +
    pub state: String,
269 +
    pub target_state: String,
270 +
    pub conflicted: bool,
271 +
    pub conflicts: Vec<ConflictView>,
272 +
    pub files: Vec<FileRows>,
273 +
    /// Atom per (path, new line number) of the projected state.
274 +
    pub atoms: HashMap<(String, usize), (String, u32)>,
275 +
    pub sealing_keys: Vec<String>,
276 +
    pub touched: Vec<String>,
277 +
    pub owners: Vec<String>,
278 +
    pub landed_already: bool,
279 +
}
280 +
281 +
/// Compute the projection and everything derived from it.
282 +
pub async fn load(app: &Shared, repo: &RepoName, row: pdb::ProposalRow) -> Result<Loaded, crate::repos::RepoError> {
283 +
    let fork_dir = match (row.source_kind.as_str(), row.source_repo_id) {
284 +
        ("fork", Some(id)) => {
285 +
            db::repos::get_by_id(&app.db, id).await.ok().flatten().and_then(|r| RepoName::new(&r.owner, &r.name)).map(|n| app.repos.dir(&n))
286 +
        }
287 +
        _ => None,
288 +
    };
289 +
    let r2 = row.clone();
290 +
    let loaded = app
291 +
        .repos
292 +
        .read(repo, move |r, cache| {
293 +
            let source = match (r2.source_kind.as_str(), &fork_dir) {
294 +
                ("changes", _) => Source::Changes(r2.source_changes().iter().filter_map(|h| ChangeHash::from_hex(h)).collect()),
295 +
                ("fork", Some(dir)) => {
296 +
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
297 +
                    let heads =
298 +
                        fork_repo.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
299 +
                    // Bring the fork's changes over now, so the projection below can read them.
300 +
                    let fork_state = fork_repo.load_state(&heads)?;
301 +
                    let missing: Vec<ChangeHash> = fork_state.changes().map(|(h, _)| *h).filter(|h| !r.store().has(h)).collect();
302 +
                    if !missing.is_empty() {
303 +
                        r.import_bundle(&fork_repo.bundle_for(&missing)?, r.current_policy()?.is_some())?;
304 +
                    }
305 +
                    Source::Changes(heads)
306 +
                }
307 +
                _ => {
308 +
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == r2.source_bookmark).map(|b| b.heads).unwrap_or_default();
309 +
                    Source::Bookmark(heads)
310 +
                }
311 +
            };
312 +
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
313 +
            let target_loaded = cache.load(r, &p.target_heads)?;
314 +
            let files = render_files(&p.files, &p.bases);
315 +
            let mut atoms = HashMap::new();
316 +
            for f in &files {
317 +
                for l in &f.lines {
318 +
                    if let Some(n) = l.new_no {
319 +
                        if let Some((h, i)) = pl::atom_at(&p.loaded, &f.path, n as usize) {
320 +
                            atoms.insert((f.path.clone(), n as usize), (h.to_hex(), i));
321 +
                        }
322 +
                    }
323 +
                }
324 +
            }
325 +
            let touched: Vec<String> = p.files.iter().map(|f| f.path.clone()).collect();
326 +
            let owners = pl::owners_for(&target_loaded, &touched);
327 +
            let sealing = pl::sealing_keys(r, &p.hashes)?.into_iter().map(|k| k.to_hex()).collect();
328 +
            let landed_already = !p.hashes.is_empty() && p.hashes.iter().all(|h| target_loaded.state.contains(h));
329 +
            Ok(Loaded {
330 +
                row: r2,
331 +
                changes: p.changes,
332 +
                hashes: p.hashes,
333 +
                heads: p.heads,
334 +
                state: p.state.to_hex(),
335 +
                target_state: p.target_state.to_hex(),
336 +
                conflicted: p.conflicted,
337 +
                conflicts: p.conflicts,
338 +
                files,
339 +
                atoms,
340 +
                sealing_keys: sealing,
341 +
                touched,
342 +
                owners,
343 +
                landed_already,
344 +
            })
345 +
        })
346 +
        .await?;
347 +
    // Remember the projection for the list, the API and other proposals' stacking checks.
348 +
    let heads_csv = loaded.heads.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
349 +
    let changes_csv = loaded.hashes.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(",");
350 +
    pdb::set_projection(&app.db, loaded.row.id, &loaded.state, &heads_csv, &loaded.target_state, &changes_csv, loaded.conflicted)
351 +
        .await
352 +
        .ok();
353 +
    Ok(loaded)
354 +
}
355 +
356 +
/// Reviews, approvals and blockers.
357 +
pub struct Verdict {
358 +
    pub reviews: Vec<ReviewView>,
359 +
    pub approvals: Vec<Approval>,
360 +
    pub blockers: Vec<String>,
361 +
    pub depends_on: Vec<i64>,
362 +
    pub requirements: pdb::Requirements,
363 +
}
364 +
365 +
pub async fn verdict(app: &Shared, loaded: &Loaded, repo_id: i64) -> Result<Verdict, sqlx::Error> {
366 +
    let rows = pdb::reviews(&app.db, loaded.row.id).await?;
367 +
    let links = accounts::identity_accounts(&app.db).await?;
368 +
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
369 +
    // Latest verdict per reviewer.
370 +
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
371 +
    for r in &rows {
372 +
        latest.insert(r.author.clone(), r);
373 +
    }
374 +
    let mut approvals = Vec::new();
375 +
    for (who, r) in &latest {
376 +
        if r.verdict == "approve" {
377 +
            let sealed = sealed_accounts.contains(who);
378 +
            let key = links.iter().find(|(k, u)| u == who && loaded.sealing_keys.contains(k)).map(|(k, _)| k.clone());
379 +
            approvals.push(Approval { account: who.clone(), sealed, key });
380 +
        }
381 +
    }
382 +
    let reviews = rows
383 +
        .iter()
384 +
        .map(|r| ReviewView {
385 +
            row: r.clone(),
386 +
            body_html: render::markdown::render(&r.body, "/", "/"),
387 +
            sealed: r.verdict == "approve" && sealed_accounts.contains(&r.author),
388 +
        })
389 +
        .collect();
390 +
    let requirements = pdb::requirements(&app.db, repo_id).await?;
391 +
    let mut blockers = Vec::new();
392 +
    if loaded.row.state == "draft" {
393 +
        blockers.push("the proposal is a draft".into());
394 +
    }
395 +
    if loaded.hashes.is_empty() {
396 +
        blockers.push("the proposal adds no changes".into());
397 +
    }
398 +
    let sealed_count = approvals.iter().filter(|a| a.sealed).count() as i64;
399 +
    if sealed_count < requirements.required_approvals {
400 +
        blockers.push(format!("{sealed_count} of {} required sealed approvals", requirements.required_approvals));
401 +
    }
402 +
    if requirements.require_owners {
403 +
        let missing: Vec<&String> = loaded.owners.iter().filter(|o| !approvals.iter().any(|a| a.sealed && &a.account == *o)).collect();
404 +
        if !missing.is_empty() {
405 +
            blockers.push(format!("code owners still to approve: {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
406 +
        }
407 +
    }
408 +
    if loaded.conflicted && !requirements.allow_conflicts {
409 +
        blockers.push("landing would carry conflicts, which this repository refuses".into());
410 +
    }
411 +
    let requesting: Vec<&String> = latest.iter().filter(|(_, r)| r.verdict == "changes").map(|(who, _)| who).collect();
412 +
    if !requesting.is_empty() {
413 +
        blockers.push(format!("changes requested by {}", requesting.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
414 +
    }
415 +
    // Stacking: another open, earlier proposal whose changes this one includes.
416 +
    let mut depends_on = Vec::new();
417 +
    for other in pdb::list(&app.db, repo_id, &["open", "draft"]).await? {
418 +
        if other.id == loaded.row.id || other.number > loaded.row.number {
419 +
            continue;
420 +
        }
421 +
        let theirs = other.projected_changes();
422 +
        if !theirs.is_empty() && theirs.iter().all(|h| loaded.hashes.iter().any(|m| m.to_hex() == *h)) {
423 +
            depends_on.push(other.number);
424 +
        }
425 +
    }
426 +
    if !depends_on.is_empty() {
427 +
        blockers.push(format!("depends on {}", depends_on.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ")));
428 +
    }
429 +
    Ok(Verdict { reviews, approvals, blockers, depends_on, requirements })
430 +
}
431 +
432 +
#[derive(Template)]
433 +
#[template(path = "proposal.html")]
434 +
pub struct ProposalPage {
435 +
    shell: Shell,
436 +
    repo: RepoShell,
437 +
    p: pdb::ProposalRow,
438 +
    body_html: String,
439 +
    tab: String,
440 +
    changes: Vec<ChangeView>,
441 +
    files: Vec<FileRows>,
442 +
    conflicts: Vec<ConflictView>,
443 +
    conflicted: bool,
444 +
    comments: Vec<CommentView>,
445 +
    /// Anchored comments by (path, current line).
446 +
    placed: HashMap<(String, usize), Vec<usize>>,
447 +
    outdated: Vec<usize>,
448 +
    reviews: Vec<ReviewView>,
449 +
    approvals: Vec<Approval>,
450 +
    blockers: Vec<String>,
451 +
    depends_on: Vec<i64>,
452 +
    events: Vec<pdb::EventRow>,
453 +
    owners: Vec<String>,
454 +
    requirements: pdb::Requirements,
455 +
    landed_already: bool,
456 +
    can_edit: bool,
457 +
    can_land: bool,
458 +
    hub_can_land: bool,
459 +
    notice: Option<String>,
460 +
    error: Option<String>,
461 +
    heads: Vec<String>,
462 +
    state_hash: String,
463 +
    source_label: String,
464 +
    api_url: String,
465 +
}
466 +
467 +
#[derive(Deserialize)]
468 +
pub struct DetailQuery {
469 +
    tab: Option<String>,
470 +
}
471 +
472 +
/// The page assembler every action ends in; its arguments are the route
473 +
/// plus what to say, and a struct would only rename them.
474 +
#[allow(clippy::too_many_arguments)]
475 +
async fn detail(
476 +
    app: &Shared,
477 +
    shell: Shell,
478 +
    viewer: &Viewer,
479 +
    owner: &str,
480 +
    name: &str,
481 +
    number: i64,
482 +
    tab: String,
483 +
    notice: Option<String>,
484 +
    error: Option<String>,
485 +
) -> Result<Page<ProposalPage>, Error> {
486 +
    let (repo, row, role) = lookup(app, &shell, viewer, owner, name).await?;
487 +
    let p = pdb::get(&app.db, row.id, number)
488 +
        .await
489 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
490 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
491 +
    let loaded = load(app, &repo, p).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
492 +
    // A proposal whose changes all reached the target landed by another route.
493 +
    if loaded.landed_already && loaded.row.is_open() {
494 +
        pdb::set_state(&app.db, loaded.row.id, "landed", &loaded.target_state).await.ok();
495 +
        pdb::add_event(&app.db, loaded.row.id, "", "landed", "every change reached the target").await.ok();
496 +
    }
497 +
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(loaded.row.clone());
498 +
    let v = verdict(app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
499 +
    let comment_rows = pdb::comments(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
500 +
    let events = pdb::events(&app.db, loaded.row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
501 +
    // Place anchored comments at their atom's current line.
502 +
    let mut by_atom: HashMap<(String, String, u32), usize> = HashMap::new();
503 +
    for ((path, line), (h, i)) in &loaded.atoms {
504 +
        by_atom.insert((path.clone(), h.clone(), *i), *line);
505 +
    }
506 +
    let mut comments = Vec::new();
507 +
    let mut placed: HashMap<(String, usize), Vec<usize>> = HashMap::new();
508 +
    let mut outdated = Vec::new();
509 +
    for (idx, c) in comment_rows.into_iter().enumerate() {
510 +
        let line = if c.anchor_change.is_empty() {
511 +
            None
512 +
        } else {
513 +
            by_atom.get(&(c.path.clone(), c.anchor_change.clone(), c.anchor_index as u32)).copied()
514 +
        };
515 +
        if !c.anchor_change.is_empty() {
516 +
            match line {
517 +
                Some(l) => placed.entry((c.path.clone(), l)).or_default().push(idx),
518 +
                None => outdated.push(idx),
519 +
            }
520 +
        }
521 +
        comments.push(CommentView { body_html: render::markdown::render(&c.body, "/", "/"), row: c, line });
522 +
    }
523 +
    let mut changes = loaded.changes.clone();
524 +
    super::attribute(app, &mut changes.iter_mut().collect::<Vec<_>>()).await;
525 +
    let me = viewer.account();
526 +
    let can_edit = me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Maintain);
527 +
    let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty();
528 +
    let hub_can_land = app.repos.read(&repo, |r, _| Ok(r.current_policy()?.is_none())).await.unwrap_or(false);
529 +
    let source_label = match p.source_kind.as_str() {
530 +
        "fork" => format!(
531 +
            "fork {}",
532 +
            db::repos::get_by_id(&app.db, p.source_repo_id.unwrap_or(0))
533 +
                .await
534 +
                .ok()
535 +
                .flatten()
536 +
                .map(|r| format!("{}:{}", r.slug(), p.source_bookmark))
537 +
                .unwrap_or_default()
538 +
        ),
539 +
        "changes" => format!("{} changes", p.source_changes().len()),
540 +
        _ => p.source_bookmark.clone(),
541 +
    };
542 +
    Ok(Page(ProposalPage {
543 +
        shell,
544 +
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
545 +
        body_html: render::markdown::render(&p.body, "/", "/"),
546 +
        p,
547 +
        tab,
548 +
        changes,
549 +
        files: loaded.files,
550 +
        conflicts: loaded.conflicts,
551 +
        conflicted: loaded.conflicted,
552 +
        comments,
553 +
        placed,
554 +
        outdated,
555 +
        reviews: v.reviews,
556 +
        approvals: v.approvals,
557 +
        blockers: v.blockers,
558 +
        depends_on: v.depends_on,
559 +
        events,
560 +
        owners: loaded.owners,
561 +
        requirements: v.requirements,
562 +
        landed_already: loaded.landed_already,
563 +
        can_edit,
564 +
        can_land,
565 +
        hub_can_land,
566 +
        notice,
567 +
        error,
568 +
        heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
569 +
        state_hash: loaded.state,
570 +
        source_label,
571 +
        api_url: format!("{}/api/v1/repos/{owner}/{name}/proposals/{number}", app.config.base_url()),
572 +
    }))
573 +
}
574 +
575 +
pub async fn show(
576 +
    State(app): State<Shared>,
577 +
    viewer: Viewer,
578 +
    Path((owner, name, number)): Path<(String, String, i64)>,
579 +
    Query(q): Query<DetailQuery>,
580 +
) -> Result<Page<ProposalPage>, Error> {
581 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
582 +
    detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await
583 +
}
584 +
585 +
// ---------------------------------------------------------------- actions
586 +
587 +
#[derive(Deserialize)]
588 +
pub struct CommentForm {
589 +
    #[serde(rename = "_csrf")]
590 +
    csrf_token: String,
591 +
    body: String,
592 +
    #[serde(default)]
593 +
    path: String,
594 +
    #[serde(default)]
595 +
    line: Option<usize>,
596 +
}
597 +
598 +
pub async fn comment(
599 +
    State(app): State<Shared>,
600 +
    viewer: Viewer,
601 +
    Path((owner, name, number)): Path<(String, String, i64)>,
602 +
    Form(f): Form<CommentForm>,
603 +
) -> Result<Page<ProposalPage>, Error> {
604 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
605 +
    let me = require(&viewer, &shell)?;
606 +
    csrf(&viewer, &f.csrf_token, &shell)?;
607 +
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
608 +
    let p = pdb::get(&app.db, row.id, number)
609 +
        .await
610 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
611 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
612 +
    if f.body.trim().is_empty() {
613 +
        return detail(
614 +
            &app,
615 +
            shell,
616 +
            &viewer,
617 +
            &owner,
618 +
            &name,
619 +
            number,
620 +
            "conversation".into(),
621 +
            None,
622 +
            Some("An empty comment says nothing.".into()),
623 +
        )
624 +
        .await;
625 +
    }
626 +
    let anchor = match (f.path.trim(), f.line) {
627 +
        ("", _) | (_, None) => None,
628 +
        (path, Some(line)) => {
629 +
            let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
630 +
            loaded.atoms.get(&(path.to_string(), line)).cloned()
631 +
        }
632 +
    };
633 +
    let tab = if anchor.is_some() { "files" } else { "conversation" };
634 +
    pdb::add_comment(
635 +
        &app.db,
636 +
        p.id,
637 +
        Some(me.account.id),
638 +
        &me.account.username,
639 +
        f.body.trim(),
640 +
        f.path.trim(),
641 +
        anchor.as_ref().map(|(h, i)| (h.as_str(), *i as i64)),
642 +
    )
643 +
    .await
644 +
    .map_err(|e| Error::from_db(shell.clone(), e))?;
645 +
    detail(&app, shell, &viewer, &owner, &name, number, tab.into(), None, None).await
646 +
}
647 +
648 +
#[derive(Deserialize)]
649 +
pub struct ReviewForm {
650 +
    #[serde(rename = "_csrf")]
651 +
    csrf_token: String,
652 +
    verdict: String,
653 +
    #[serde(default)]
654 +
    body: String,
655 +
}
656 +
657 +
pub async fn review(
658 +
    State(app): State<Shared>,
659 +
    viewer: Viewer,
660 +
    Path((owner, name, number)): Path<(String, String, i64)>,
661 +
    Form(f): Form<ReviewForm>,
662 +
) -> Result<Page<ProposalPage>, Error> {
663 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
664 +
    let me = require(&viewer, &shell)?;
665 +
    csrf(&viewer, &f.csrf_token, &shell)?;
666 +
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
667 +
    let p = pdb::get(&app.db, row.id, number)
668 +
        .await
669 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
670 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
671 +
    if !matches!(f.verdict.as_str(), "comment" | "changes" | "approve") {
672 +
        return Err(Error::bad(shell, "a review is comment, changes or approve"));
673 +
    }
674 +
    if f.verdict == "approve" && Some(me.account.id) == p.author_id {
675 +
        return detail(
676 +
            &app,
677 +
            shell,
678 +
            &viewer,
679 +
            &owner,
680 +
            &name,
681 +
            number,
682 +
            "conversation".into(),
683 +
            None,
684 +
            Some("You cannot approve your own proposal.".into()),
685 +
        )
686 +
        .await;
687 +
    }
688 +
    pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim())
689 +
        .await
690 +
        .map_err(|e| Error::from_db(shell.clone(), e))?;
691 +
    pdb::add_event(&app.db, p.id, &me.account.username, &format!("review.{}", f.verdict), "").await.ok();
692 +
    let notice = if f.verdict == "approve" {
693 +
        Some(format!(
694 +
            "Approval recorded. It counts once you seal the changes: oxy seal --proposal {}/{owner}/{name}/proposal/{number}",
695 +
            app.config.base_url()
696 +
        ))
697 +
    } else {
698 +
        None
699 +
    };
700 +
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), notice, None).await
701 +
}
702 +
703 +
#[derive(Deserialize)]
704 +
pub struct EditForm {
705 +
    #[serde(rename = "_csrf")]
706 +
    csrf_token: String,
707 +
    title: String,
708 +
    #[serde(default)]
709 +
    body: String,
710 +
    target: String,
711 +
}
712 +
713 +
pub async fn edit(
714 +
    State(app): State<Shared>,
715 +
    viewer: Viewer,
716 +
    Path((owner, name, number)): Path<(String, String, i64)>,
717 +
    Form(f): Form<EditForm>,
718 +
) -> Result<Page<ProposalPage>, Error> {
719 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
720 +
    let me = require(&viewer, &shell)?;
721 +
    csrf(&viewer, &f.csrf_token, &shell)?;
722 +
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
723 +
    let p = pdb::get(&app.db, row.id, number)
724 +
        .await
725 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
726 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
727 +
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
728 +
        return Err(Error::not_found(shell));
729 +
    }
730 +
    let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target };
731 +
    pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?;
732 +
    pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok();
733 +
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Saved.".into()), None).await
734 +
}
735 +
736 +
#[derive(Deserialize)]
737 +
pub struct StateForm {
738 +
    #[serde(rename = "_csrf")]
739 +
    csrf_token: String,
740 +
    action: String,
741 +
}
742 +
743 +
pub async fn state(
744 +
    State(app): State<Shared>,
745 +
    viewer: Viewer,
746 +
    Path((owner, name, number)): Path<(String, String, i64)>,
747 +
    Form(f): Form<StateForm>,
748 +
) -> Result<Page<ProposalPage>, Error> {
749 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
750 +
    let me = require(&viewer, &shell)?;
751 +
    csrf(&viewer, &f.csrf_token, &shell)?;
752 +
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
753 +
    let p = pdb::get(&app.db, row.id, number)
754 +
        .await
755 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
756 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
757 +
    if Some(me.account.id) != p.author_id && role < Role::Triage {
758 +
        return Err(Error::not_found(shell));
759 +
    }
760 +
    let new_state = match (f.action.as_str(), p.state.as_str()) {
761 +
        ("close", "open" | "draft") => "closed",
762 +
        ("reopen", "closed") => "open",
763 +
        ("ready", "draft") => "open",
764 +
        ("draft", "open") => "draft",
765 +
        _ => return Err(Error::bad(shell, "that transition is not possible")),
766 +
    };
767 +
    pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
768 +
    pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok();
769 +
    detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await
770 +
}
771 +
772 +
pub async fn resolve_comment(
773 +
    State(app): State<Shared>,
774 +
    viewer: Viewer,
775 +
    Path((owner, name, number, id)): Path<(String, String, i64, i64)>,
776 +
    Form(f): Form<super::session::CsrfForm>,
777 +
) -> Result<Page<ProposalPage>, Error> {
778 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
779 +
    require(&viewer, &shell)?;
780 +
    csrf(&viewer, &f.csrf, &shell)?;
781 +
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
782 +
    let p = pdb::get(&app.db, row.id, number)
783 +
        .await
784 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
785 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
786 +
    let current: Option<bool> = sqlx::query_scalar("SELECT resolved FROM proposal_comments WHERE id = ? AND proposal_id = ?")
787 +
        .bind(id)
788 +
        .bind(p.id)
789 +
        .fetch_optional(&app.db)
790 +
        .await
791 +
        .map_err(|e| Error::from_db(shell.clone(), e))?;
792 +
    if let Some(r) = current {
793 +
        pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
794 +
    }
795 +
    detail(&app, shell, &viewer, &owner, &name, number, "files".into(), None, None).await
796 +
}
797 +
798 +
/// Land from the hub: only when the repository has no trust policy (the
799 +
/// hub never signs a reference update the policy would have to admit).
800 +
pub async fn land(
801 +
    State(app): State<Shared>,
802 +
    viewer: Viewer,
803 +
    Path((owner, name, number)): Path<(String, String, i64)>,
804 +
    Form(f): Form<super::session::CsrfForm>,
805 +
) -> Result<Page<ProposalPage>, Error> {
806 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
807 +
    let me = require(&viewer, &shell)?;
808 +
    csrf(&viewer, &f.csrf, &shell)?;
809 +
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
810 +
    let p = pdb::get(&app.db, row.id, number)
811 +
        .await
812 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
813 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
814 +
    let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
815 +
    let v = verdict(&app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
816 +
    if !p.is_open() || !v.blockers.is_empty() {
817 +
        return detail(
818 +
            &app,
819 +
            shell,
820 +
            &viewer,
821 +
            &owner,
822 +
            &name,
823 +
            number,
824 +
            "conversation".into(),
825 +
            None,
826 +
            Some(format!("Cannot land: {}", v.blockers.join("; "))),
827 +
        )
828 +
        .await;
829 +
    }
830 +
    let target = p.target.clone();
831 +
    let heads = loaded.heads.clone();
832 +
    let result = app
833 +
        .repos
834 +
        .write(&repo, move |r, _| {
835 +
            if r.current_policy()?.is_some() {
836 +
                return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(
837 +
                    "this repository has a trust policy, so a reference update must be signed by an authorised key: land from a client with oxy proposal land".into(),
838 +
                )));
839 +
            }
840 +
            r.bookmark_set(&target, &heads)?;
841 +
            Ok(pl::state_ref(&heads).to_hex())
842 +
        })
843 +
        .await;
844 +
    match result {
845 +
        Ok(state) => {
846 +
            pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| Error::from_db(shell.clone(), e))?;
847 +
            pdb::add_event(&app.db, p.id, &me.account.username, "landed", "from the hub").await.ok();
848 +
            accounts::audit(&app.db, Some(&me.account), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok();
849 +
            db::repos::record_push(
850 +
                &app.db,
851 +
                row.id,
852 +
                Some((me.account.id, me.account.username.as_str())),
853 +
                &[db::repos::PushOutcome {
854 +
                    reference: p.target.clone(),
855 +
                    ok: true,
856 +
                    reason: format!("landed #{number}"),
857 +
                    old_state: loaded.target_state.clone(),
858 +
                    new_state: state,
859 +
                    heads: loaded.heads.len() as i64,
860 +
                    changes: loaded.hashes.len() as i64,
861 +
                }],
862 +
            )
863 +
            .await
864 +
            .ok();
865 +
            detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await
866 +
        }
867 +
        Err(e) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(e.to_string())).await,
868 +
    }
869 +
}
870 +
871 +
// -------------------------------------------------------------------- API
872 +
873 +
pub async fn to_api(app: &Shared, owner: &str, name: &str, loaded: &Loaded, v: &Verdict, source_url: Option<String>) -> hub_api::Proposal {
874 +
    let p = &loaded.row;
875 +
    hub_api::Proposal {
876 +
        number: p.number,
877 +
        title: p.title.clone(),
878 +
        body: p.body.clone(),
879 +
        author: p.author.clone(),
880 +
        state: ProposalState::parse(&p.state).unwrap_or(ProposalState::Open),
881 +
        target: p.target.clone(),
882 +
        source: match p.source_kind.as_str() {
883 +
            "fork" => {
884 +
                let (o, n) = source_url
885 +
                    .as_deref()
886 +
                    .and_then(|u| u.rsplit_once('/'))
887 +
                    .and_then(|(b, n)| b.rsplit_once('/').map(|(_, o)| (o.to_string(), n.to_string())))
888 +
                    .unwrap_or_default();
889 +
                ProposalSource::Fork { owner: o, name: n, bookmark: p.source_bookmark.clone() }
890 +
            }
891 +
            "changes" => ProposalSource::Changes { hashes: p.source_changes() },
892 +
            _ => ProposalSource::Bookmark { bookmark: p.source_bookmark.clone() },
893 +
        },
894 +
        changes: loaded
895 +
            .changes
896 +
            .iter()
897 +
            .map(|c| hub_api::ChangeSummary {
898 +
                hash: c.hash.clone(),
899 +
                message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
900 +
                author: hub_api::Author {
901 +
                    name: c.author.name.clone(),
902 +
                    email: c.author.email.clone(),
903 +
                    key: c.author.key.clone(),
904 +
                    account: c.author.account.clone(),
905 +
                },
906 +
                when_ms: c.when_ms,
907 +
                deps: c.deps.clone(),
908 +
                head: c.head,
909 +
                signature: match &c.signature {
910 +
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
911 +
                    view::SigView::Valid { key, sealed, revoked, .. } => {
912 +
                        hub_api::Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }
913 +
                    }
914 +
                    view::SigView::Invalid(why) => hub_api::Signature::Invalid { reason: why.clone() },
915 +
                },
916 +
            })
917 +
            .collect(),
918 +
        projected_heads: loaded.heads.iter().map(|h| h.to_hex()).collect(),
919 +
        projected_state: Some(loaded.state.clone()),
920 +
        conflicted: loaded.conflicted,
921 +
        approvals: v.approvals.clone(),
922 +
        landable: p.is_open() && v.blockers.is_empty(),
923 +
        blockers: v.blockers.clone(),
924 +
        created_ms: p.created_ms,
925 +
        updated_ms: p.updated_ms,
926 +
        url: format!("{}/{owner}/{name}/proposal/{}", app.config.base_url(), p.number),
927 +
        repository_url: format!("{}/{owner}/{name}", app.config.base_url()),
928 +
        source_url,
929 +
    }
930 +
}
931 +
932 +
/// The line comment form's placement key, for templates.
933 +
pub fn key(path: &str, line: impl filters::Num) -> (String, usize) {
934 +
    (path.to_string(), line.as_u64() as usize)
935 +
}
936 +
937 +
#[allow(dead_code)]
938 +
fn _unused(_: &diff::Line, _: &ConflictView, _: &Role) {}
added crates/hub/tests/proposals.rs +260 −0
1 +
//! Milestone 0.3 end to end: a proposal from a bookmark and from a fork,
2 +
//! an atom-anchored comment, a seal-backed approval, requirements and a
3 +
//! landing from the hub.
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 +
}
58 +
59 +
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>, bearer: Option<&str>) -> Res {
60 +
    let mut req = Request::builder().method(method).uri(path);
61 +
    if let Some(c) = cookie {
62 +
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
63 +
    }
64 +
    if let Some(b) = bearer {
65 +
        req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
66 +
    }
67 +
    let body = match form {
68 +
        Some(f) => {
69 +
            req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
70 +
            Body::from(f.to_string())
71 +
        }
72 +
        None => Body::empty(),
73 +
    };
74 +
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
75 +
    let status = res.status();
76 +
    let cookie = res
77 +
        .headers()
78 +
        .get(header::SET_COOKIE)
79 +
        .and_then(|v| v.to_str().ok())
80 +
        .and_then(|v| v.split(';').next())
81 +
        .and_then(|kv| kv.split_once('='))
82 +
        .map(|(_, v)| v.to_string());
83 +
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
84 +
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie }
85 +
}
86 +
87 +
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
88 +
    send(app, "GET", path, cookie, None, None).await
89 +
}
90 +
91 +
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res {
92 +
    send(app, "POST", path, cookie, Some(form), None).await
93 +
}
94 +
95 +
async fn register(app: &Shared, user: &str) -> String {
96 +
    let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await;
97 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
98 +
    r.cookie.unwrap()
99 +
}
100 +
101 +
fn csrf_of(page: &str) -> String {
102 +
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
103 +
    page[i..].split('"').next().unwrap().to_string()
104 +
}
105 +
106 +
#[tokio::test(flavor = "multi_thread")]
107 +
async fn propose_review_seal_and_land() {
108 +
    let h = hub().await;
109 +
    let app = &h.app;
110 +
    let andy = register(app, "andy").await;
111 +
    let bob = register(app, "bob").await;
112 +
113 +
    // A repository with main and a feature bookmark, pushed by Andy's client.
114 +
    let repo = RepoName::parse("andy/demo").unwrap();
115 +
    app.repos.create(&repo, None).unwrap();
116 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
117 +
    let work = tempfile::tempdir().unwrap();
118 +
    let mut client = Repo::init(work.path()).unwrap();
119 +
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
120 +
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
121 +
    client.config.set("remote.origin.token", "site").unwrap();
122 +
    client.config.set("core.sign", "change").unwrap();
123 +
    client.save_config().unwrap();
124 +
    write(work.path(), "a.txt", b"one\ntwo\n");
125 +
    write(work.path(), ".oxyowners", b"* @andy\n");
126 +
    client.snapshot("t").unwrap();
127 +
    let base = client.commit("base", &[]).unwrap();
128 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
129 +
    client.bookmark_create("feature", Some(&[base])).unwrap();
130 +
    client.bookmark_switch("feature", false).unwrap();
131 +
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
132 +
    client.snapshot("t").unwrap();
133 +
    let feat = client.commit("add three", &[]).unwrap();
134 +
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
135 +
136 +
    // Bob previews and opens a proposal feature → main.
137 +
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await;
138 +
    assert_eq!(preview.status, StatusCode::OK);
139 +
    assert!(preview.body.contains("1 change") && preview.body.contains("add three"), "{}", preview.body);
140 +
    let csrf = csrf_of(&preview.body);
141 +
    let r =
142 +
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=Add+three&body=Please&target=main&source=feature"))
143 +
            .await;
144 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
145 +
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await;
146 +
    assert_eq!(page.status, StatusCode::OK);
147 +
    assert!(page.body.contains("Add three") && page.body.contains("1 change"), "{}", page.body);
148 +
    assert!(page.body.contains("Please"));
149 +
    assert!(get(app, "/andy/demo/proposals", None).await.body.contains("#1"));
150 +
151 +
    // A comment anchored to line 3 of a.txt lands in the thread on the files tab.
152 +
    let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await;
153 +
    assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body);
154 +
    let csrf_b = csrf_of(&files.body);
155 +
    let r = post(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await;
156 +
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
157 +
    assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body);
158 +
    let c = db::proposals::comments(&app.db, 1).await.unwrap();
159 +
    assert_eq!(c[0].anchor_change, feat.to_hex(), "anchored to the atom the feature change introduced");
160 +
161 +
    // Requirements: one sealed approval and code owners.
162 +
    let settings = get(app, "/andy/demo/settings", Some(&andy)).await;
163 +
    let csrf_a = csrf_of(&settings.body);
164 +
    assert_eq!(
165 +
        post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1"))
166 +
            .await
167 +
            .status,
168 +
        StatusCode::OK
169 +
    );
170 +
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
171 +
    assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body);
172 +
    assert!(page.body.contains("code owners still to approve: andy"), "{}", page.body);
173 +
174 +
    // Bob cannot approve his own; Andy approves, which is pending until sealed.
175 +
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body);
176 +
    let r = post(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await;
177 +
    assert!(r.body.contains("cannot approve your own"), "{}", r.body);
178 +
    let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await;
179 +
    assert!(r.body.contains("seal pending"), "{}", r.body);
180 +
    assert!(r.body.contains("oxy seal --proposal"));
181 +
182 +
    // Andy links his key, seals the proposal's change and pushes the seal: the approval counts.
183 +
    let key = client.config.author_key.unwrap();
184 +
    let identity = client.identity(&key).unwrap();
185 +
    db::accounts::link_identity(
186 +
        &app.db,
187 +
        db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id,
188 +
        &key.to_hex(),
189 +
        &identity.name,
190 +
        &identity.email,
191 +
        &identity.encode(),
192 +
    )
193 +
    .await
194 +
    .unwrap();
195 +
    let seal = client.seal(&[feat]).unwrap();
196 +
    client.push_seals("origin", &[seal]).unwrap();
197 +
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await;
198 +
    assert!(page.body.contains("approved and sealed"), "{}", page.body);
199 +
    assert!(page.body.contains("ready to land"), "{}", page.body);
200 +
    // The API agrees, and names the projected heads for `oxy proposal land`.
201 +
    let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await;
202 +
    assert_eq!(api.status, StatusCode::OK);
203 +
    let v: serde_json::Value = serde_json::from_str(&api.body).unwrap();
204 +
    assert_eq!(v["landable"], true, "{}", api.body);
205 +
    assert_eq!(v["approvals"][0]["sealed"], true);
206 +
    assert_eq!(v["projected_heads"][0], feat.to_hex());
207 +
    assert_eq!(v["changes"][0]["hash"], feat.to_hex());
208 +
209 +
    // Land from the hub (no policy): main moves to the projected heads.
210 +
    let csrf_a = csrf_of(&page.body);
211 +
    let r = post(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await;
212 +
    assert!(r.body.contains("Landed."), "{}", r.body);
213 +
    let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap();
214 +
    assert_eq!(heads, vec![feat]);
215 +
    assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1"));
216 +
    assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1"));
217 +
218 +
    // A proposal from a fork: a copy of the repository with the same id.
219 +
    let fork = RepoName::parse("bob/demo").unwrap();
220 +
    app.repos.create(&fork, Some(client.id().unwrap())).unwrap();
221 +
    db::repos::create(&app.db, "bob", "demo", "fork", hub_api::Visibility::Public).await.unwrap();
222 +
    db::accounts::set_collaborator(
223 +
        &app.db,
224 +
        db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap().id,
225 +
        Some(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id),
226 +
        None,
227 +
        "admin",
228 +
    )
229 +
    .await
230 +
    .unwrap();
231 +
    client.config.set("remote.fork.url", &format!("{}/bob/demo", h.base)).unwrap();
232 +
    client.config.set("remote.fork.token", "site").unwrap();
233 +
    client.bookmark_switch("main", false).unwrap();
234 +
    client.fetch("origin").unwrap();
235 +
    client.bookmark_set("main", &[feat]).unwrap();
236 +
    client.bookmark_create("fix", Some(&[feat])).unwrap();
237 +
    client.bookmark_switch("fix", false).unwrap();
238 +
    write(work.path(), "b.txt", b"b\n");
239 +
    client.snapshot("t").unwrap();
240 +
    let fix = client.commit("add b", &[]).unwrap();
241 +
    assert!(client.push_bookmark("fork", "fix", false).unwrap().iter().all(|r| r.ok));
242 +
    let preview = get(app, "/andy/demo/proposals/new?source=bob/demo:fix&target=main", Some(&bob)).await;
243 +
    assert!(preview.body.contains("add b"), "fork preview: {}", preview.body);
244 +
    let csrf = csrf_of(&preview.body);
245 +
    let r =
246 +
        post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+fork&target=main&source=bob/demo:fix")).await;
247 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
248 +
    let page = get(app, "/andy/demo/proposal/2?tab=changes", Some(&andy)).await;
249 +
    assert!(page.body.contains("add b"), "{}", page.body);
250 +
    assert!(
251 +
        app.repos.read(&repo, move |r, _| Ok(r.store().has(&fix))).await.unwrap(),
252 +
        "the fork's change was copied into the target's store"
253 +
    );
254 +
    // Closing and reopening.
255 +
    let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body);
256 +
    let r = post(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await;
257 +
    assert!(r.body.contains(">closed<"), "{}", r.body);
258 +
    let r = post(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await;
259 +
    assert!(r.body.contains(">open<"), "{}", r.body);
260 +
}
added templates/proposal.html +142 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}#{{ p.number }} {{ p.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %}
6 +
{% block mainclass %} class="wide"{% endblock %}
7 +
{% block content %}
8 +
<div class="head"><h1>{{ p.title }} <small class="px">#{{ p.number }}</small>
9 +
  <small>
10 +
    <span class="tag{% if p.state == "landed" %} head{% else if p.state == "closed" %} bad{% endif %}">{{ p.state }}</span>
11 +
    {{ p.author }} wants <span class="lbl lbl-bm">{{ source_label }}</span> into <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a>
12 +
    · {{ changes.len()|plural("change", "changes") }} · opened <time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time>
13 +
  </small>
14 +
</h1></div>
15 +
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
16 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
17 +
{% if conflicted %}<div class="strip bad">Landing would carry {{ conflicts.len()|plural("conflict", "conflicts") }}. Any collaborator can push a resolution change to the source; the proposal follows it. <a href="?tab=files#conflicts">See them</a></div>{% endif %}
18 +
{% if landed_already && p.state != "landed" %}<div class="strip warn">Every change is already in {{ p.target }}.</div>{% endif %}
19 +
<div class="bar">
20 +
  <span class="modes">
21 +
    <a href="?tab=conversation"{% if tab == "conversation" %} class="on"{% endif %}>conversation ({{ comments.len() + reviews.len() }})</a>
22 +
    <a href="?tab=changes"{% if tab == "changes" %} class="on"{% endif %}>changes ({{ changes.len() }})</a>
23 +
    <a href="?tab=files"{% if tab == "files" %} class="on"{% endif %}>files ({{ files.len() }})</a>
24 +
  </span>
25 +
  <span class="small mute">projected state <code title="{{ state_hash }}">{{ state_hash|short }}</code> · {{ heads.len()|plural("head", "heads") }} · <a href="{{ repo.url }}/tree/{% if let Some(h) = heads.first() %}{{ h }}{% endif %}">browse</a> · <a href="{{ api_url }}">json</a></span>
26 +
</div>
27 +
28 +
{% if tab == "changes" %}
29 +
{% if changes.is_empty() %}<p class="empty">Nothing to add: the target already has everything.</p>{% endif %}
30 +
{% for c in changes %}{% call m::change_row(repo, c) %}{% endfor %}
31 +
32 +
{% else if tab == "files" %}
33 +
{% if !conflicts.is_empty() %}
34 +
<section class="panel" id="conflicts"><h2>Conflicts the landing would carry</h2>
35 +
{% for c in conflicts %}<p><strong>{{ c.path }}</strong> <span class="tag bad">{{ c.kind }}</span></p>{% if let Some(t) = c.marked %}<pre class="marked">{{ t }}</pre>{% endif %}{% endfor %}
36 +
</section>
37 +
{% endif %}
38 +
{% for f in files %}
39 +
<section class="file-diff" id="{{ f.anchor }}">
40 +
  <header><span class="kind kind-{{ f.kind }}">{{ f.kind }}</span> <strong>{{ f.path }}</strong> <span class="counts"><span class="add">+{{ f.added }}</span> <span class="del">−{{ f.removed }}</span></span></header>
41 +
  {% if f.lines.is_empty() %}<p class="empty">No line changes.</p>{% else %}
42 +
  <table class="code diff">
43 +
  {% for l in f.lines %}
44 +
    <tr class="{{ l.kind.class() }}">
45 +
      <td class="n">{% if let Some(n) = l.old_no %}{{ n }}{% endif %}</td>
46 +
      <td class="n">{% if let Some(n) = l.new_no %}{{ n }}{% endif %}</td>
47 +
      <td class="s">{{ l.kind.sign() }}</td>
48 +
      <td class="t"><pre>{{ l.text }}</pre>{% if let Some(n) = l.new_no %}{% if shell.user.is_some() && p.is_open() %}<details class="linecomment"><summary title="Comment on this line">+</summary>
49 +
        <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="path" value="{{ f.path }}"><input type="hidden" name="line" value="{{ n }}"><textarea name="body" rows="3" required placeholder="Anchored to this line's atom; it follows the line through rebases."></textarea><button type="submit">COMMENT</button></form></details>{% endif %}{% endif %}</td>
50 +
    </tr>
51 +
    {% if let Some(n) = l.new_no %}{% if let Some(idxs) = placed.get(&crate::web::proposals::key(&f.path, n)) %}
52 +
    <tr class="ctx"><td colspan="4" class="thread">
53 +
      {% for i in idxs %}{% let c = comments[*i] %}
54 +
      <div class="comment{% if c.row.resolved %} resolved{% endif %}"><span class="who">{{ c.row.author }}</span> <time class="mute" title="{{ c.row.created_ms|when_i }}">{{ c.row.created_ms|ago_i }}</time>{% if c.row.resolved %} <span class="tag">resolved</span>{% endif %}
55 +
        {% if shell.user.is_some() %}<form class="tiny" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment/{{ c.row.id }}/resolve"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>{% if c.row.resolved %}unresolve{% else %}resolve{% endif %}</button></form>{% endif %}
56 +
        <div class="body">{{ c.body_html|safe }}</div></div>
57 +
      {% endfor %}
58 +
    </td></tr>
59 +
    {% endif %}{% endif %}
60 +
  {% endfor %}
61 +
  </table>
62 +
  {% endif %}
63 +
</section>
64 +
{% endfor %}
65 +
{% if !outdated.is_empty() %}
66 +
<section class="panel"><h2>Comments on lines that are gone</h2>
67 +
{% for i in outdated %}{% let c = comments[*i] %}<div class="comment"><span class="who">{{ c.row.author }}</span> on <code>{{ c.row.path }}</code> <time class="mute">{{ c.row.created_ms|ago_i }}</time><div class="body">{{ c.body_html|safe }}</div></div>{% endfor %}
68 +
</section>
69 +
{% endif %}
70 +
71 +
{% else %}
72 +
<div class="side-layout">
73 +
<div>
74 +
  {% if !p.body.is_empty() %}<article class="doc">{{ body_html|safe }}</article>{% endif %}
75 +
  {% for c in comments %}{% if c.row.anchor_change.is_empty() %}
76 +
  <div class="comment"><span class="who">{{ c.row.author }}</span> <time class="mute" title="{{ c.row.created_ms|when_i }}">{{ c.row.created_ms|ago_i }}</time><div class="body">{{ c.body_html|safe }}</div></div>
77 +
  {% endif %}{% endfor %}
78 +
  {% for r in reviews %}
79 +
  <div class="comment review-{{ r.row.verdict }}"><span class="who">{{ r.row.author }}</span> {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}commented{% endif %} <time class="mute" title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time>{% if !r.row.body.is_empty() %}<div class="body">{{ r.body_html|safe }}</div>{% endif %}</div>
80 +
  {% endfor %}
81 +
  {% for e in events %}<p class="small mute">{{ e.actor }} {{ e.event }}{% if !e.detail.is_empty() %}: {{ e.detail }}{% endif %} · {{ e.at_ms|ago_i }}</p>{% endfor %}
82 +
  {% if shell.user.is_some() %}
83 +
  <form class="card wide" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment">
84 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
85 +
    <label for="body">Comment (Markdown)</label><textarea id="body" name="body" class="md" rows="4" required></textarea>
86 +
    <button type="submit">COMMENT</button>
87 +
  </form>
88 +
  {% if p.is_open() %}
89 +
  <form class="card wide" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/review">
90 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
91 +
    <h2>Review</h2>
92 +
    <label for="rbody">Summary (optional)</label><textarea id="rbody" name="body" rows="3"></textarea>
93 +
    <div class="choice">
94 +
      <label><input type="radio" name="verdict" value="comment" checked> comment</label>
95 +
      <label><input type="radio" name="verdict" value="approve"> approve</label>
96 +
      <label><input type="radio" name="verdict" value="changes"> request changes</label>
97 +
    </div>
98 +
    <p class="small mute">An approval counts once you seal the changes from a client: <code>oxy seal --proposal {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</code></p>
99 +
    <button type="submit">SUBMIT REVIEW</button>
100 +
  </form>
101 +
  {% endif %}
102 +
  {% endif %}
103 +
</div>
104 +
<aside class="side">
105 +
  <h3>Landing</h3>
106 +
  {% if p.state == "landed" %}<p><span class="sig sig-valid">✔</span> landed{% if !p.landed_state.is_empty() %} at <code>{{ p.landed_state|short }}</code>{% endif %}</p>
107 +
  {% else if p.state == "closed" %}<p class="mute">closed</p>
108 +
  {% else %}
109 +
    {% if blockers.is_empty() %}<p><span class="sig sig-valid">✔</span> ready to land</p>{% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %}
110 +
    {% if can_land %}
111 +
      {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">LAND</button></form>
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 +
    {% endif %}
114 +
  {% endif %}
115 +
  <h3>Approvals</h3>
116 +
  {% if approvals.is_empty() %}<p class="mute small">None yet{% if requirements.required_approvals > 0 %}; {{ requirements.required_approvals }} sealed required{% endif %}.</p>{% else %}
117 +
  <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 %}
118 +
  {% if !owners.is_empty() %}<h3>Code owners</h3><ul class="plain">{% for o in owners %}<li>{{ o }}</li>{% endfor %}</ul>{% endif %}
119 +
  {% if !depends_on.is_empty() %}<h3>Depends on</h3><ul class="plain">{% for n in depends_on %}<li><a href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a></li>{% endfor %}</ul>{% endif %}
120 +
  {% if can_edit %}
121 +
  <h3>Edit</h3>
122 +
  <form class="card" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit">
123 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
124 +
    <label for="etitle">Title</label><input type="text" id="etitle" name="title" value="{{ p.title }}">
125 +
    <label for="etarget">Target</label><input type="text" id="etarget" name="target" value="{{ p.target }}">
126 +
    <label for="ebody">Description</label><textarea id="ebody" name="body" rows="4">{{ p.body }}</textarea>
127 +
    <button type="submit">SAVE</button>
128 +
  </form>
129 +
  <form class="card" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/state">
130 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
131 +
    <div class="choice">
132 +
    {% if p.state == "draft" %}<button type="submit" name="action" value="ready">READY FOR REVIEW</button>{% endif %}
133 +
    {% if p.state == "open" %}<button type="submit" name="action" value="draft">BACK TO DRAFT</button>{% endif %}
134 +
    {% if p.is_open() %}<button type="submit" name="action" value="close">CLOSE</button>{% endif %}
135 +
    {% if p.state == "closed" %}<button type="submit" name="action" value="reopen">REOPEN</button>{% endif %}
136 +
    </div>
137 +
  </form>
138 +
  {% endif %}
139 +
</aside>
140 +
</div>
141 +
{% endif %}
142 +
{% endblock %}
added templates/proposal_new.html +34 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}New proposal · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>New proposal <small>add a bookmark's changes to another bookmark</small></h1></div>
8 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 +
<form class="card wide" method="get" action="{{ repo.url }}/proposals/new">
10 +
  <div class="row">
11 +
    <div><label for="source">Source (a bookmark here, or owner/name:bookmark of a fork)</label><input type="text" id="source" name="source" value="{{ source }}" list="bms"></div>
12 +
    <div><label for="target">Target</label><select id="target" name="target">{% for b in bookmarks %}<option value="{{ b }}"{% if *b == target %} selected{% endif %}>{{ b }}</option>{% endfor %}</select></div>
13 +
    <div><button type="submit">PREVIEW</button></div>
14 +
  </div>
15 +
  <datalist id="bms">{% for b in bookmarks %}<option value="{{ b }}">{% endfor %}</datalist>
16 +
</form>
17 +
{% if !preview.is_empty() %}
18 +
<section class="panel">
19 +
  <h2>{{ preview.len()|plural("change", "changes") }} would be added to {{ target }}{% if conflicted %} <span class="tag bad">with conflicts</span>{% endif %}</h2>
20 +
  {% for c in preview %}{% call m::change_row(repo, c) %}{% endfor %}
21 +
</section>
22 +
<form class="card wide" method="post" action="{{ repo.url }}/proposals/new">
23 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
24 +
  <input type="hidden" name="source" value="{{ source }}">
25 +
  <input type="hidden" name="target" value="{{ target }}">
26 +
  <label for="title">Title</label><input type="text" id="title" name="title" required value="{% if preview.len() == 1 %}{{ preview[0].title }}{% endif %}">
27 +
  <label for="body">Description (Markdown)</label><textarea id="body" name="body" class="md" rows="8"></textarea>
28 +
  <div class="choice"><label><input type="checkbox" name="draft" value="1"> draft: not ready for review</label></div>
29 +
  <button type="submit">OPEN PROPOSAL</button>
30 +
</form>
31 +
{% else if !source.is_empty() %}
32 +
<p class="empty">{{ source }} adds nothing to {{ target }}.</p>
33 +
{% endif %}
34 +
{% endblock %}
added templates/proposals.html +33 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Proposals · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>Proposals <small>changes offered for a bookmark; they never go stale</small></h1></div>
8 +
<div class="bar">
9 +
  <span class="modes">
10 +
    <a href="?state=open"{% if filter == "open" %} class="on"{% endif %}>{{ counts.0 }} open</a>
11 +
    <a href="?state=landed"{% if filter == "landed" %} class="on"{% endif %}>{{ counts.1 }} landed</a>
12 +
    <a href="?state=closed"{% if filter == "closed" %} class="on"{% endif %}>{{ counts.2 }} closed</a>
13 +
  </span>
14 +
  {% if shell.user.is_some() %}<a class="go small" href="{{ repo.url }}/proposals/new">new proposal</a>{% endif %}
15 +
</div>
16 +
{% if rows.is_empty() %}
17 +
<p class="empty">No {{ filter }} proposals. Push a bookmark, then <a href="{{ repo.url }}/proposals/new">propose it</a> for {{ repo.default_bookmark }}.{% if bookmarks.len() > 1 %} Bookmarks here: {% for b in bookmarks %}<a href="{{ repo.url }}/proposals/new?source={{ b }}">{{ b }}</a>{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %}</p>
18 +
{% else %}
19 +
<ul class="rows">
20 +
{% for p in rows %}
21 +
  <li>
22 +
    <div>
23 +
      <a class="name" href="{{ repo.url }}/proposal/{{ p.number }}"><span class="mute">#{{ p.number }}</span> <strong>{{ p.title }}</strong></a>
24 +
      {% if p.state == "draft" %}<span class="tag">draft</span>{% endif %}
25 +
      {% if p.conflicted %}<span class="tag bad">conflicts</span>{% endif %}
26 +
      <p class="small">{{ p.author }} wants <span class="lbl lbl-bm">{{ p.source_bookmark }}</span> into <span class="lbl lbl-bm">{{ p.target }}</span> · {{ p.projected_changes().len()|plural("change", "changes") }}</p>
27 +
    </div>
28 +
    <div class="right small">updated <time title="{{ p.updated_ms|when_i }}">{{ p.updated_ms|ago_i }}</time></div>
29 +
  </li>
30 +
{% endfor %}
31 +
</ul>
32 +
{% endif %}
33 +
{% endblock %}
modified README.md +7 −3
…
8 unchanged lines
9 9
10 10
## Status
11 11
12 -
Milestones 0.1 (browse) and 0.2 (accounts and access) are done: pages show
12 +
Milestones 0.1 (browse), 0.2 (accounts and access) and 0.3 (proposals and
13 +
review) are done: pages show
13 14
trees, files, blame, history with the dependency graph, changes with diffs,
14 15
comparisons, bookmarks, tags, conflicts, the trust policy and the push log;
15 16
accounts sign in with a password and optional TOTP, create repositories and
16 17
organisations, hold roles, mint scoped tokens for `oxy`, and link their
17 -
Oxygen keys so signed changes are attributed to them. Proposals, issues and
18 -
the rest follow the milestones in the specification.
18 +
Oxygen keys so signed changes are attributed to them; proposals offer a
19 +
bookmark's changes for another with the resulting state, diff and conflicts
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.
19 23
20 24
## Running
21 25
…
32 unchanged lines
modified crates/hub-api/src/lib.rs +81 −0
…
120 unchanged lines
121 121
        assert_eq!(Visibility::parse("secret"), None);
122 122
    }
123 123
}
124 +
125 +
// ---------------------------------------------------------------- proposals
126 +
127 +
/// A proposal: a request to add changes to a target bookmark (hub spec §7).
128 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
129 +
pub struct Proposal {
130 +
    pub number: i64,
131 +
    pub title: String,
132 +
    pub body: String,
133 +
    pub author: String,
134 +
    pub state: ProposalState,
135 +
    pub target: String,
136 +
    pub source: ProposalSource,
137 +
    /// The changes the proposal adds, dependency order, newest last.
138 +
    pub changes: Vec<ChangeSummary>,
139 +
    /// Heads of the projected state (target plus the changes), when computed.
140 +
    pub projected_heads: Vec<String>,
141 +
    pub projected_state: Option<String>,
142 +
    pub conflicted: bool,
143 +
    pub approvals: Vec<Approval>,
144 +
    pub landable: bool,
145 +
    pub blockers: Vec<String>,
146 +
    pub created_ms: i64,
147 +
    pub updated_ms: i64,
148 +
    /// `https://hub/{owner}/{name}/proposal/{number}`.
149 +
    pub url: String,
150 +
    /// Where `oxy` fetches the changes from.
151 +
    pub repository_url: String,
152 +
    /// The fork's clone URL when the source is a fork.
153 +
    pub source_url: Option<String>,
154 +
}
155 +
156 +
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
157 +
#[serde(rename_all = "lowercase")]
158 +
pub enum ProposalState {
159 +
    Draft,
160 +
    Open,
161 +
    Landed,
162 +
    Closed,
163 +
}
164 +
165 +
impl ProposalState {
166 +
    pub fn as_str(self) -> &'static str {
167 +
        match self {
168 +
            ProposalState::Draft => "draft",
169 +
            ProposalState::Open => "open",
170 +
            ProposalState::Landed => "landed",
171 +
            ProposalState::Closed => "closed",
172 +
        }
173 +
    }
174 +
175 +
    pub fn parse(s: &str) -> Option<ProposalState> {
176 +
        Some(match s {
177 +
            "draft" => ProposalState::Draft,
178 +
            "open" => ProposalState::Open,
179 +
            "landed" => ProposalState::Landed,
180 +
            "closed" => ProposalState::Closed,
181 +
            _ => return None,
182 +
        })
183 +
    }
184 +
}
185 +
186 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187 +
#[serde(tag = "kind", rename_all = "lowercase")]
188 +
pub enum ProposalSource {
189 +
    /// A bookmark of the same repository.
190 +
    Bookmark { bookmark: String },
191 +
    /// A bookmark of a fork.
192 +
    Fork { owner: String, name: String, bookmark: String },
193 +
    /// An explicit list of change hashes.
194 +
    Changes { hashes: Vec<String> },
195 +
}
196 +
197 +
/// A reviewer's approval, counted only when backed by a seal over every
198 +
/// change of the proposal signed with a key linked to the reviewer.
199 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200 +
pub struct Approval {
201 +
    pub account: String,
202 +
    pub sealed: bool,
203 +
    pub key: Option<String>,
204 +
}
modified crates/hub/src/api/mod.rs +64 −0
…
15 unchanged lines
16 16
        .route("/repos/{owner}/{name}", get(get_repo))
17 17
        .route("/repos/{owner}/{name}/bookmarks", get(bookmarks))
18 18
        .route("/repos/{owner}/{name}/tags", get(tags))
19 +
        .route("/repos/{owner}/{name}/proposals", get(proposals))
20 +
        .route("/repos/{owner}/{name}/proposals/{number}", get(proposal))
21 +
}
22 +
23 +
/// The account a bearer token resolves to, for API calls that must see
24 +
/// what the caller may see.
25 +
async fn caller(app: &Shared, headers: &axum::http::HeaderMap) -> Result<Option<db::accounts::Account>, ApiError> {
26 +
    let Some(t) = oxy_server::bearer_token(headers) else { return Ok(None) };
27 +
    if app.config.auth.push_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
28 +
        // A site token acts as an administrator.
29 +
        return Ok(db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin));
30 +
    }
31 +
    let token = db::accounts::token_by_hash(&app.db, &crate::auth::token_hash(t)).await?;
32 +
    match token {
33 +
        Some(tok) if !tok.expired(db::now_ms()) => Ok(db::accounts::account_by_id(&app.db, tok.account_id).await?),
34 +
        _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())),
35 +
    }
36 +
}
37 +
38 +
async fn readable(
39 +
    app: &Shared,
40 +
    headers: &axum::http::HeaderMap,
41 +
    owner: &str,
42 +
    name: &str,
43 +
) -> Result<(RepoName, db::repos::RepoRow), ApiError> {
44 +
    let repo = RepoName::new(owner, name).ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
45 +
    let row = db::repos::get(&app.db, owner, name).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such repository".into()))?;
46 +
    let account = caller(app, headers).await?;
47 +
    let role = crate::auth::roles::role_for(&app.db, account.as_ref(), &row).await?;
48 +
    if !crate::auth::roles::allows(role, crate::auth::Access::Read) {
49 +
        return Err(ApiError(StatusCode::NOT_FOUND, "no such repository".into()));
50 +
    }
51 +
    Ok((repo, row))
52 +
}
53 +
54 +
async fn proposals(
55 +
    State(app): State<Shared>,
56 +
    headers: axum::http::HeaderMap,
57 +
    Path((owner, name)): Path<(String, String)>,
58 +
) -> Result<Json<Vec<hub_api::Proposal>>, ApiError> {
59 +
    let (repo, row) = readable(&app, &headers, &owner, &name).await?;
60 +
    let mut out = Vec::new();
61 +
    for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? {
62 +
        let loaded = crate::web::proposals::load(&app, &repo, p).await?;
63 +
        let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
64 +
        out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await);
65 +
    }
66 +
    Ok(Json(out))
67 +
}
68 +
69 +
async fn proposal(
70 +
    State(app): State<Shared>,
71 +
    headers: axum::http::HeaderMap,
72 +
    Path((owner, name, number)): Path<(String, String, i64)>,
73 +
) -> Result<Json<hub_api::Proposal>, ApiError> {
74 +
    let (repo, row) = readable(&app, &headers, &owner, &name).await?;
75 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or(ApiError(StatusCode::NOT_FOUND, "no such proposal".into()))?;
76 +
    let source_url = match p.source_repo_id {
77 +
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
78 +
        None => None,
79 +
    };
80 +
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
81 +
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
82 +
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
19 83
}
20 84
21 85
pub struct ApiError(StatusCode, String);
…
64 unchanged lines
modified crates/hub/src/db/mod.rs +1 −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 proposals;
5 6
pub mod repos;
6 7
7 8
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
…
23 unchanged lines
modified crates/hub/src/repos/mod.rs +1 −0
…
6 unchanged lines
7 7
8 8
pub mod cache;
9 9
pub mod names;
10 +
pub mod proposals;
10 11
pub mod view;
11 12
12 13
pub use cache::{Loaded, StateCache};
…
146 unchanged lines
modified crates/hub/src/repos/view.rs +24 −4
…
201 unchanged lines
202 202
    Ok(out)
203 203
}
204 204
205 +
pub fn change_view_pub(
206 +
    repo: &Repo,
207 +
    hash: &ChangeHash,
208 +
    meta: &oxy_core::state::ChangeMeta,
209 +
    head: bool,
210 +
    labels: &HashMap<ChangeHash, Vec<Label>>,
211 +
) -> ChangeView {
212 +
    change_view(repo, hash, meta, head, labels)
213 +
}
214 +
205 215
fn change_view(
206 216
    repo: &Repo,
207 217
    hash: &ChangeHash,
…
516 unchanged lines
724 734
}
725 735
726 736
pub fn conflicts(repo: &Repo, cache: &StateCache) -> Result<Vec<ConflictView>> {
727 -
    let labels = head_labels(repo)?;
728 737
    let mut out = Vec::new();
729 738
    for b in repo.bookmarks()? {
730 739
        let loaded = cache.load(repo, &b.heads)?;
740 +
        out.extend(conflicts_in(repo, &loaded, &b.name));
741 +
    }
742 +
    Ok(out)
743 +
}
744 +
745 +
/// The conflicts of one loaded state, labelled with `bookmark`.
746 +
pub fn conflicts_in(repo: &Repo, loaded: &super::cache::Loaded, bookmark: &str) -> Vec<ConflictView> {
747 +
    let labels = head_labels(repo).unwrap_or_default();
748 +
    let mut out = Vec::new();
749 +
    {
750 +
        let b_name = bookmark.to_string();
731 751
        let state = &loaded.state;
732 752
        let snapshot = &loaded.snapshot;
733 753
        for c in &snapshot.tree.conflicts {
734 754
            let (path, kind) = describe_tree_conflict(snapshot, c);
735 -
            out.push(ConflictView { bookmark: b.name.clone(), path, kind, marked: None, contributors: vec![] });
755 +
            out.push(ConflictView { bookmark: b_name.clone(), path, kind, marked: None, contributors: vec![] });
736 756
        }
737 757
        for (path, f) in &snapshot.files {
738 758
            if !f.is_conflicted() {
…
13 unchanged lines
752 772
                FileContent::Opaque { blobs } => (format!("replace ({} candidates)", blobs.len()), None, vec![]),
753 773
                FileContent::Dir => continue,
754 774
            };
755 -
            out.push(ConflictView { bookmark: b.name.clone(), path: path.clone(), kind, marked, contributors });
775 +
            out.push(ConflictView { bookmark: b_name.clone(), path: path.clone(), kind, marked, contributors });
756 776
        }
757 777
    }
758 -
    Ok(out)
778 +
    out
759 779
}
760 780
761 781
fn describe_tree_conflict(snapshot: &Snapshot, c: &TreeConflict) -> (String, String) {
…
140 unchanged lines
modified crates/hub/src/web/mod.rs +11 −0
…
7 unchanged lines
8 8
mod history;
9 9
mod home;
10 10
mod orgs;
11 +
pub mod proposals;
11 12
mod refs;
12 13
mod repo;
13 14
mod repo_settings;
…
67 unchanged lines
81 82
        .route("/{owner}/{name}/settings", get(repo_settings::settings).post(repo_settings::update))
82 83
        .route("/{owner}/{name}/settings/collaborators", post(repo_settings::collaborator))
83 84
        .route("/{owner}/{name}/settings/delete", post(repo_settings::delete))
85 +
        .route("/{owner}/{name}/settings/requirements", post(repo_settings::requirements))
86 +
        .route("/{owner}/{name}/proposals", get(proposals::list))
87 +
        .route("/{owner}/{name}/proposals/new", get(proposals::new_form).post(proposals::create))
88 +
        .route("/{owner}/{name}/proposal/{number}", get(proposals::show))
89 +
        .route("/{owner}/{name}/proposal/{number}/comment", post(proposals::comment))
90 +
        .route("/{owner}/{name}/proposal/{number}/comment/{id}/resolve", post(proposals::resolve_comment))
91 +
        .route("/{owner}/{name}/proposal/{number}/review", post(proposals::review))
92 +
        .route("/{owner}/{name}/proposal/{number}/edit", post(proposals::edit))
93 +
        .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state))
94 +
        .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land))
84 95
        .route("/{owner}/{name}/oxy/v1/{endpoint}", any(crate::protocol::dispatch).layer(DefaultBodyLimit::max(push_limit)))
85 96
        .fallback(not_found)
86 97
        .layer(axum::middleware::from_fn_with_state(app.clone(), same_origin))
…
284 unchanged lines
modified crates/hub/src/web/repo_settings.rs +43 −2
…
85 unchanged lines
86 86
    collaborators: Vec<accounts::Collaborator>,
87 87
    teams: Vec<accounts::Team>,
88 88
    role: Role,
89 +
    requirements: db::proposals::Requirements,
89 90
    notice: Option<String>,
90 91
    error: Option<String>,
91 92
}
…
11 unchanged lines
103 104
        Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
104 105
        None => vec![],
105 106
    };
106 -
    let repo = RepoShell::new(app, &row, "settings");
107 -
    Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, notice, error }))
107 +
    let repo = RepoShell::new(app, &row, "settings").with_role(role);
108 +
    let requirements = db::proposals::requirements(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
109 +
    Ok(Page(RepoSettingsPage { shell, repo, row, collaborators, teams, role, requirements, notice, error }))
108 110
}
109 111
110 112
pub async fn settings(
…
129 unchanged lines
240 242
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
241 243
    Ok(Redirect::to(&format!("/{owner}")).into_response())
242 244
}
245 +
246 +
#[derive(Deserialize)]
247 +
pub struct RequirementsForm {
248 +
    #[serde(rename = "_csrf")]
249 +
    csrf_token: String,
250 +
    #[serde(default)]
251 +
    required_approvals: i64,
252 +
    #[serde(default)]
253 +
    require_owners: Option<String>,
254 +
    #[serde(default)]
255 +
    allow_conflicts: Option<String>,
256 +
}
257 +
258 +
pub async fn requirements(
259 +
    State(app): State<Shared>,
260 +
    viewer: Viewer,
261 +
    Path((owner, name)): Path<(String, String)>,
262 +
    Form(f): Form<RequirementsForm>,
263 +
) -> Result<Page<RepoSettingsPage>, Error> {
264 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
265 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
266 +
    csrf(&viewer, &f.csrf_token, &shell)?;
267 +
    let r = db::proposals::Requirements {
268 +
        required_approvals: f.required_approvals.max(0),
269 +
        require_owners: f.require_owners.is_some(),
270 +
        allow_conflicts: f.allow_conflicts.is_some(),
271 +
    };
272 +
    db::proposals::set_requirements(&app.db, row.id, r).await.map_err(|e| Error::from_db(shell.clone(), e))?;
273 +
    accounts::audit(
274 +
        &app.db,
275 +
        viewer.account(),
276 +
        "repo.requirements",
277 +
        &format!("{owner}/{name}"),
278 +
        &format!("{} approvals, owners {}, conflicts {}", r.required_approvals, r.require_owners, r.allow_conflicts),
279 +
    )
280 +
    .await
281 +
    .ok();
282 +
    settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await
283 +
}
modified docs/plan.md +14 −1
…
29 unchanged lines
30 30
- [ ] Email verification and password reset by email (needs SMTP, hub 0.4 with notifications).
31 31
- [ ] Passkeys (1.0).
32 32
33 +
## 0.3 Proposals and review (2026-09-27)
34 +
35 +
- [x] Schema: proposals with a per-repository number space shared with issues, comments (atom anchored), reviews, events; landing requirements on repositories.
36 +
- [x] Projection (`repos/proposals.rs`): union of the target's heads and the source (bookmark, fork with objects copied from the fork's store, or explicit changes), the changes added, the diff and the conflicts the landing would carry, recomputed on every view and remembered for lists, the API and stacking.
37 +
- [x] Comments anchored to atoms: a line comment records `(change, index)`; it is placed at the atom's current line, or listed as outdated when the line is gone.
38 +
- [x] Reviews: comment, request changes, approve; an approval counts when the reviewer's linked key has sealed every change (`oxy seal --proposal URL`, pushed on its own with `push_seals`); no self-approval.
39 +
- [x] Requirements: sealed approvals, code owners from `.oxyowners`, conflicts refused unless allowed, outstanding change requests, drafts, stacking on earlier open proposals.
40 +
- [x] Landing: from the hub when the repository has no policy; otherwise the page gives `oxy proposal land URL`, which fetches the projection, sets the bookmark and pushes with the user's key. A proposal whose changes all reach the target by any route is marked landed.
41 +
- [x] Pages: list with counts, new with preview, detail with conversation, changes and files tabs; JSON API for proposals used by `oxy proposal show|land` and `oxy seal --proposal`.
42 +
- [x] Tests: `tests/proposals.rs` covers bookmark and fork sources, anchoring, approval before and after sealing, requirements, landing.
43 +
- [ ] Notifications for review requests, mentions and landings (0.4).
44 +
- [ ] Hub-side landing under a policy through the delegate key (needs the attestation delegate rule in oxy, see spec §18).
45 +
33 46
## Later
34 47
35 -
- 0.3 proposals and review · 0.4 issues and notifications · 0.5 API, webhooks, checks · 0.6 web edits, releases, mirror · 0.7 runner · 1.0 hardening.
48 +
- 0.4 issues and notifications · 0.5 API, webhooks, checks · 0.6 web edits, releases, mirror · 0.7 runner · 1.0 hardening.
36 49
37 50
## Notes
38 51
…
8 unchanged lines
modified templates/pushes.html +1 −1
…
8 unchanged lines
9 9
<div class="table"><table>
10 10
<tr><th>When</th><th>Who</th><th>Reference</th><th>Result</th><th>Changes</th><th>Heads</th><th>State</th></tr>
11 11
{% for p in pushes %}
12 -
<tr><td><time title="{{ p.at_ms|when_i }}">{{ p.at_ms|ago_i }}</time></td><td>{% if p.pusher.is_empty() %}<span class="mute">anonymous</span>{% else if p.pusher == "site token" %}<span class="mute">site token</span>{% else %}<a href="/{{ p.pusher }}">{{ p.pusher }}</a>{% endif %}</td><td class="px">{{ p.reference }}</td><td>{% if p.ok %}<span class="sig sig-valid">✔</span> accepted{% else %}<span class="sig sig-invalid">✘</span> refused: {{ p.reason }}{% endif %}</td><td>{% if p.ok %}{{ p.changes }}{% endif %}</td><td>{% if p.ok %}{{ p.heads }}{% endif %}</td><td class="small mute">{% if !p.new_state.is_empty() %}<code title="{{ p.old_state }} → {{ p.new_state }}">{{ p.new_state|short }}</code>{% endif %}</td></tr>
12 +
<tr><td><time title="{{ p.at_ms|when_i }}">{{ p.at_ms|ago_i }}</time></td><td>{% if p.pusher.is_empty() %}<span class="mute">anonymous</span>{% else if p.pusher == "site token" %}<span class="mute">site token</span>{% else %}<a href="/{{ p.pusher }}">{{ p.pusher }}</a>{% endif %}</td><td class="px">{{ p.reference }}</td><td>{% if p.ok %}<span class="sig sig-valid">✔</span> accepted{% if !p.reason.is_empty() %} · {{ p.reason }}{% endif %}{% else %}<span class="sig sig-invalid">✘</span> refused: {{ p.reason }}{% endif %}</td><td>{% if p.ok %}{{ p.changes }}{% endif %}</td><td>{% if p.ok %}{{ p.heads }}{% endif %}</td><td class="small mute">{% if !p.new_state.is_empty() %}<code title="{{ p.old_state }} → {{ p.new_state }}">{{ p.new_state|short }}</code>{% endif %}</td></tr>
13 13
{% endfor %}
14 14
</table></div>
15 15
{% endif %}
…
1 unchanged lines
modified templates/repo_settings.html +10 −0
…
19 unchanged lines
20 20
  </div>
21 21
  <button type="submit">SAVE</button>
22 22
</form>
23 +
<form class="card wide" method="post" action="{{ repo.url }}/settings/requirements">
24 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
25 +
  <h2>Landing requirements</h2>
26 +
  <label for="required_approvals">Sealed approvals required</label><input type="number" id="required_approvals" name="required_approvals" min="0" value="{{ requirements.required_approvals }}">
27 +
  <div class="choice">
28 +
    <label><input type="checkbox" name="require_owners" value="1"{% if requirements.require_owners %} checked{% endif %}> code owners from <code>.oxyowners</code> must approve</label>
29 +
    <label><input type="checkbox" name="allow_conflicts" value="1"{% if requirements.allow_conflicts %} checked{% endif %}> allow landing with conflicts</label>
30 +
  </div>
31 +
  <button type="submit">SAVE</button>
32 +
</form>
23 33
{% if role == crate::auth::Role::Admin %}
24 34
<section class="panel">
25 35
  <h2>Collaborators</h2>
…
28 unchanged lines
modified templates/repo_tabs.html +1 −0
…
3 unchanged lines
4 4
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.visibility != "public" %} <span class="tag">{{ repo.visibility }}</span>{% endif %}</span>
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 +
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals</a>
7 8
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on"{% endif %}>Bookmarks</a>
8 9
    <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on"{% endif %}>Tags</a>
9 10
    <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} class="on"{% endif %}>Conflicts</a>
…
50 unchanged lines
modified templates/style.css +16 −0
…
251 unchanged lines
252 252
table.small td, table.small th { padding: 5px 8px; font-size: 13px; }
253 253
form.tiny { display: inline; margin: 0; }
254 254
form.tiny button { padding: 1px 6px; font-size: 10px; }
255 +
256 +
/* proposals */
257 +
.comment { background: var(--panel); border: 1px solid var(--line); padding: 10px 14px; margin: 0 0 10px; }
258 +
.comment .who { font-weight: 600; }
259 +
.comment .body { margin-top: 6px; }
260 +
.comment .body p:last-child { margin: 0; }
261 +
.comment.resolved { opacity: .6; }
262 +
.comment.review-approve { border-color: var(--ok); }
263 +
.comment.review-changes { border-color: var(--bad); }
264 +
td.thread { background: var(--soft); padding: 8px 12px; }
265 +
td.thread .comment { margin: 4px 0; font-family: "Barlow", sans-serif; font-size: 14px; }
266 +
details.linecomment { display: inline; float: right; }
267 +
details.linecomment summary { list-style: none; cursor: pointer; color: var(--dim); font-size: 12px; padding: 0 4px; }
268 +
details.linecomment summary:hover { color: var(--acc); }
269 +
details.linecomment form { font-family: "Barlow", sans-serif; background: var(--panel); border: 1px solid var(--acc); padding: 8px; margin: 4px 0; }
270 +
details.linecomment textarea { width: 100%; font: inherit; background: var(--bg); color: var(--ink); border: 1px solid var(--line); }