OXYGENHUB

Milestone 0.6: releases, Git mirror, code search, anchored API comments 3f4b0ea6eabf

Releases on tags with notes drafted from the changes since the previous
release, assets stored by BLAKE3, and cached .tar.zst and .zip source
archives of any state. A read-only Git mirror of the default bookmark,
exported by oxy and served by git http-backend behind the hub's access
checks. Code search by substring or regex with a budget and cache. API
comments on proposals can anchor to any line of the projected state.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 21:48 UTC
Signature
✔ signed by Andy McCall
Hash
3f4b0ea6eabf3074989245c59133f905ada2b63e707ae18eac5082b97c700339
Depends on
145306044896 18e381ea88c2 19bb31ff1f5c 9ff27da517fe b64e2537d702 d23464c0ac60 de505df5cf8d
Depended on by
1c52d940b4a0 041a945f55f8 93f445fa50d8 851a26a2a6d1 351cf57e5608 ba0a55bb5b3c dd327d76062d 166f6e9bf4f1 c7067c7bda56 0ee5c3fb32eb 16ee5d19d220 8a48dd830178 a7aa76ac52d7 d2c1d6a7d12e 97fc47fee9cf 076c7a7c86b1 c8684d44d549 ea14c4ec5c3f 5d90e0e045e2 7b17d570d7ee 9a777fec4ed9 ffb67a81188c d3b048b27277 eb2a3502601d c9aacc0e7dc1 acc85ac0f70b f677d6270298 39442b000e6b 17a3a84b00d1 e71846aa07b9 8a3334b7a364 d7ce41acf22c ea85398a51bc 739a763a558c ced6d3afc5cf 7974adb0be66 6978fd76a066 5533e91a2aa4 bf1ab3458db1 df940d75cd3d 10708ab8f54d a3c8288b0141 cc19697f8871 65be9f9c3074 c5f3bde466d1 9008bf6c2b8c 83d5052bf76c c79a1111dd56 78001759604c 756fe44a0f4d 34beb1a7e376 736f6813543d 42fa95540468 8d2caf122517 64caed18236a 1553bfc9ae53 07b1a5bbf481 4edcf24025cf b45384c909c1
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
62 atoms, 10 tree operations, 31 files

Browse the tree at this change

added crates/hub/migrations/0007_releases.sql +31 −0
1 +
-- Releases attach notes and assets to tags (hub spec §9). Tags themselves
2 +
-- live in the repository; assets are content addressed under <data>/assets.
3 +
4 +
CREATE TABLE releases (
5 +
    id          INTEGER PRIMARY KEY,
6 +
    repo_id     INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
7 +
    tag         TEXT NOT NULL,
8 +
    title       TEXT NOT NULL,
9 +
    notes       TEXT NOT NULL DEFAULT '',
10 +
    prerelease  INTEGER NOT NULL DEFAULT 0,
11 +
    author_id   INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
12 +
    author      TEXT NOT NULL DEFAULT '',
13 +
    created_ms  INTEGER NOT NULL,
14 +
    UNIQUE (repo_id, tag)
15 +
);
16 +
17 +
CREATE TABLE release_assets (
18 +
    id           INTEGER PRIMARY KEY,
19 +
    release_id   INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
20 +
    name         TEXT NOT NULL,
21 +
    size         INTEGER NOT NULL,
22 +
    hash         TEXT NOT NULL,
23 +
    content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
24 +
    downloads    INTEGER NOT NULL DEFAULT 0,
25 +
    created_ms   INTEGER NOT NULL,
26 +
    UNIQUE (release_id, name)
27 +
);
28 +
29 +
-- Git mirror settings per repository (hub spec §14).
30 +
ALTER TABLE repositories ADD COLUMN git_mirror INTEGER NOT NULL DEFAULT 0;
31 +
ALTER TABLE repositories ADD COLUMN git_mirror_state TEXT NOT NULL DEFAULT '';
added crates/hub/src/db/releases.rs +122 −0
1 +
//! Releases and their assets (hub spec §9).
2 +
3 +
use super::{now_ms, Pool};
4 +
use sqlx::FromRow;
5 +
6 +
#[derive(Clone, Debug, FromRow)]
7 +
pub struct Release {
8 +
    pub id: i64,
9 +
    pub repo_id: i64,
10 +
    pub tag: String,
11 +
    pub title: String,
12 +
    pub notes: String,
13 +
    pub prerelease: bool,
14 +
    pub author_id: Option<i64>,
15 +
    pub author: String,
16 +
    pub created_ms: i64,
17 +
}
18 +
19 +
#[derive(Clone, Debug, FromRow)]
20 +
pub struct Asset {
21 +
    pub id: i64,
22 +
    pub release_id: i64,
23 +
    pub name: String,
24 +
    pub size: i64,
25 +
    pub hash: String,
26 +
    pub content_type: String,
27 +
    pub downloads: i64,
28 +
    pub created_ms: i64,
29 +
}
30 +
31 +
pub async fn list(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Release>> {
32 +
    sqlx::query_as("SELECT * FROM releases WHERE repo_id = ? ORDER BY created_ms DESC, id DESC").bind(repo_id).fetch_all(pool).await
33 +
}
34 +
35 +
pub async fn get(pool: &Pool, repo_id: i64, tag: &str) -> sqlx::Result<Option<Release>> {
36 +
    sqlx::query_as("SELECT * FROM releases WHERE repo_id = ? AND tag = ?").bind(repo_id).bind(tag).fetch_optional(pool).await
37 +
}
38 +
39 +
/// The newest release that is not a pre-release: the "latest" marker.
40 +
pub async fn latest(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<Release>> {
41 +
    sqlx::query_as("SELECT * FROM releases WHERE repo_id = ? AND prerelease = 0 ORDER BY created_ms DESC, id DESC LIMIT 1")
42 +
        .bind(repo_id)
43 +
        .fetch_optional(pool)
44 +
        .await
45 +
}
46 +
47 +
pub async fn create(
48 +
    pool: &Pool,
49 +
    repo_id: i64,
50 +
    tag: &str,
51 +
    title: &str,
52 +
    notes: &str,
53 +
    prerelease: bool,
54 +
    author: Option<(i64, &str)>,
55 +
) -> sqlx::Result<Release> {
56 +
    sqlx::query_as("INSERT INTO releases (repo_id, tag, title, notes, prerelease, author_id, author, created_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *")
57 +
        .bind(repo_id)
58 +
        .bind(tag)
59 +
        .bind(title)
60 +
        .bind(notes)
61 +
        .bind(prerelease)
62 +
        .bind(author.map(|a| a.0))
63 +
        .bind(author.map(|a| a.1).unwrap_or(""))
64 +
        .bind(now_ms())
65 +
        .fetch_one(pool)
66 +
        .await
67 +
}
68 +
69 +
pub async fn edit(pool: &Pool, id: i64, title: &str, notes: &str, prerelease: bool) -> sqlx::Result<()> {
70 +
    sqlx::query("UPDATE releases SET title = ?, notes = ?, prerelease = ? WHERE id = ?")
71 +
        .bind(title)
72 +
        .bind(notes)
73 +
        .bind(prerelease)
74 +
        .bind(id)
75 +
        .execute(pool)
76 +
        .await?;
77 +
    Ok(())
78 +
}
79 +
80 +
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
81 +
    sqlx::query("DELETE FROM releases WHERE id = ?").bind(id).execute(pool).await?;
82 +
    Ok(())
83 +
}
84 +
85 +
pub async fn assets(pool: &Pool, release_id: i64) -> sqlx::Result<Vec<Asset>> {
86 +
    sqlx::query_as("SELECT * FROM release_assets WHERE release_id = ? ORDER BY name").bind(release_id).fetch_all(pool).await
87 +
}
88 +
89 +
pub async fn asset(pool: &Pool, release_id: i64, name: &str) -> sqlx::Result<Option<Asset>> {
90 +
    sqlx::query_as("SELECT * FROM release_assets WHERE release_id = ? AND name = ?").bind(release_id).bind(name).fetch_optional(pool).await
91 +
}
92 +
93 +
pub async fn add_asset(pool: &Pool, release_id: i64, name: &str, size: i64, hash: &str, content_type: &str) -> sqlx::Result<Asset> {
94 +
    sqlx::query_as(
95 +
        "INSERT INTO release_assets (release_id, name, size, hash, content_type, created_ms) VALUES (?, ?, ?, ?, ?, ?) \
96 +
         ON CONFLICT (release_id, name) DO UPDATE SET size = excluded.size, hash = excluded.hash, content_type = excluded.content_type, created_ms = excluded.created_ms RETURNING *",
97 +
    )
98 +
    .bind(release_id)
99 +
    .bind(name)
100 +
    .bind(size)
101 +
    .bind(hash)
102 +
    .bind(content_type)
103 +
    .bind(now_ms())
104 +
    .fetch_one(pool)
105 +
    .await
106 +
}
107 +
108 +
pub async fn delete_asset(pool: &Pool, id: i64) -> sqlx::Result<()> {
109 +
    sqlx::query("DELETE FROM release_assets WHERE id = ?").bind(id).execute(pool).await?;
110 +
    Ok(())
111 +
}
112 +
113 +
pub async fn count_download(pool: &Pool, id: i64) -> sqlx::Result<()> {
114 +
    sqlx::query("UPDATE release_assets SET downloads = downloads + 1 WHERE id = ?").bind(id).execute(pool).await?;
115 +
    Ok(())
116 +
}
117 +
118 +
/// Whether any asset of any release still names this content.
119 +
pub async fn hash_in_use(pool: &Pool, hash: &str) -> sqlx::Result<bool> {
120 +
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM release_assets WHERE hash = ?").bind(hash).fetch_one(pool).await?;
121 +
    Ok(n > 0)
122 +
}
added crates/hub/src/mirror.rs +195 −0
1 +
//! The read-only Git mirror (hub spec §14): the default bookmark exported
2 +
//! with oxy's own `git export` into `<data>/mirrors/<owner>/<name>.git`
3 +
//! after every update, and served over Git's smart HTTP by `git
4 +
//! http-backend` behind the hub's access checks. Pushing to it is refused.
5 +
6 +
use crate::auth::{self, Access};
7 +
use crate::db::{self, repos::RepoRow};
8 +
use crate::repos::RepoName;
9 +
use crate::Shared;
10 +
use axum::body::Bytes;
11 +
use axum::extract::{Path, RawQuery, State};
12 +
use axum::http::{header, HeaderMap, Method, StatusCode};
13 +
use axum::response::{IntoResponse, Response};
14 +
use std::path::PathBuf;
15 +
use tokio::io::AsyncWriteExt;
16 +
17 +
pub fn mirror_dir(app: &Shared, repo: &RepoName) -> PathBuf {
18 +
    app.config.data.join("mirrors").join(&repo.owner).join(format!("{}.git", repo.name))
19 +
}
20 +
21 +
/// Export the default bookmark into the mirror, if the mirror is on.
22 +
pub async fn refresh(app: &Shared, row: &RepoRow) {
23 +
    if !row.git_mirror {
24 +
        return;
25 +
    }
26 +
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
27 +
    let dir = mirror_dir(app, &repo);
28 +
    let bookmark = row.default_bookmark.clone();
29 +
    let result = app
30 +
        .repos
31 +
        .write(&repo, move |r, _| {
32 +
            let mut r = oxy_core::repo::Repo::open(r.root())?;
33 +
            r.config.bookmark = bookmark.clone();
34 +
            let report = r.git_export_as(&dir, &bookmark)?;
35 +
            // Point HEAD at the exported branch so clones check it out.
36 +
            std::fs::write(dir.join("HEAD"), format!("ref: refs/heads/{bookmark}\n"))?;
37 +
            let state = r.view()?.bookmarks.get(&bookmark).map(|s| s.to_hex()).unwrap_or_default();
38 +
            Ok((report.exported, state))
39 +
        })
40 +
        .await;
41 +
    match result {
42 +
        Ok((_, state)) => {
43 +
            db::repos::set_git_mirror_state(&app.db, row.id, &state).await.ok();
44 +
        }
45 +
        Err(e) => tracing::warn!("git mirror of {}: {e}", row.slug()),
46 +
    }
47 +
}
48 +
49 +
/// Who a Git client is: HTTP Basic with a personal access token as the
50 +
/// password (any user name), or anonymous.
51 +
async fn git_caller(app: &Shared, headers: &HeaderMap) -> Result<Option<db::accounts::Account>, StatusCode> {
52 +
    let Some(v) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { return Ok(None) };
53 +
    let token = if let Some(b) = v.strip_prefix("Basic ") {
54 +
        let decoded = base64_decode(b.trim()).ok_or(StatusCode::UNAUTHORIZED)?;
55 +
        let s = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?;
56 +
        s.split_once(':').map(|(_, p)| p.to_string()).ok_or(StatusCode::UNAUTHORIZED)?
57 +
    } else if let Some(t) = v.strip_prefix("Bearer ") {
58 +
        t.trim().to_string()
59 +
    } else {
60 +
        return Err(StatusCode::UNAUTHORIZED);
61 +
    };
62 +
    let tok = db::accounts::token_by_hash(&app.db, &auth::token_hash(&token)).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
63 +
    match tok {
64 +
        Some(t) if !t.expired(db::now_ms()) && (t.has_scope("repo:read") || t.has_scope("repo:write") || t.has_scope("repo:admin")) => {
65 +
            db::accounts::account_by_id(&app.db, t.account_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
66 +
        }
67 +
        _ => Err(StatusCode::UNAUTHORIZED),
68 +
    }
69 +
}
70 +
71 +
fn base64_decode(s: &str) -> Option<Vec<u8>> {
72 +
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
73 +
    let mut out = Vec::new();
74 +
    let mut buf = 0u32;
75 +
    let mut bits = 0;
76 +
    for c in s.bytes().filter(|c| *c != b'=') {
77 +
        let v = T.iter().position(|x| *x == c)? as u32;
78 +
        buf = (buf << 6) | v;
79 +
        bits += 6;
80 +
        if bits >= 8 {
81 +
            bits -= 8;
82 +
            out.push((buf >> bits) as u8);
83 +
            buf &= (1 << bits) - 1;
84 +
        }
85 +
    }
86 +
    Some(out)
87 +
}
88 +
89 +
fn challenge() -> Response {
90 +
    (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"Oxygenhub\"")], "authentication required").into_response()
91 +
}
92 +
93 +
/// Every smart-HTTP path under `/{owner}/{name}.git/`.
94 +
pub async fn serve(
95 +
    State(app): State<Shared>,
96 +
    Path((owner, name_git, rest)): Path<(String, String, String)>,
97 +
    method: Method,
98 +
    RawQuery(query): RawQuery,
99 +
    headers: HeaderMap,
100 +
    body: Bytes,
101 +
) -> Response {
102 +
    let Some(name) = name_git.strip_suffix(".git") else { return StatusCode::NOT_FOUND.into_response() };
103 +
    let Some(repo) = RepoName::new(&owner, name) else { return StatusCode::NOT_FOUND.into_response() };
104 +
    let query = query.unwrap_or_default();
105 +
    if rest.contains("git-receive-pack") || query.contains("git-receive-pack") {
106 +
        return (StatusCode::FORBIDDEN, "This is a read-only Git mirror of an Oxygen repository; push with oxy instead.").into_response();
107 +
    }
108 +
    let row = match db::repos::get(&app.db, &owner, name).await {
109 +
        Ok(Some(r)) if r.git_mirror => r,
110 +
        _ => return StatusCode::NOT_FOUND.into_response(),
111 +
    };
112 +
    let account = match git_caller(&app, &headers).await {
113 +
        Ok(a) => a,
114 +
        Err(_) => return challenge(),
115 +
    };
116 +
    let role = auth::roles::role_for(&app.db, account.as_ref(), &row).await.ok().flatten();
117 +
    if !auth::roles::allows(role, Access::Read) {
118 +
        return if account.is_none() { challenge() } else { StatusCode::NOT_FOUND.into_response() };
119 +
    }
120 +
    let dir = mirror_dir(&app, &repo);
121 +
    if !dir.join("HEAD").exists() {
122 +
        refresh(&app, &row).await;
123 +
    }
124 +
    let root = app.config.data.join("mirrors");
125 +
    let git = std::env::var("OXYHUB_GIT").unwrap_or_else(|_| "git".into());
126 +
    let mut cmd = tokio::process::Command::new(git);
127 +
    cmd.arg("http-backend")
128 +
        .env_clear()
129 +
        .env("PATH", std::env::var("PATH").unwrap_or_default())
130 +
        .env("GIT_PROJECT_ROOT", &root)
131 +
        .env("GIT_HTTP_EXPORT_ALL", "1")
132 +
        .env("PATH_INFO", format!("/{owner}/{name_git}/{rest}"))
133 +
        .env("REQUEST_METHOD", method.as_str())
134 +
        .env("QUERY_STRING", &query)
135 +
        .env("CONTENT_TYPE", headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or(""))
136 +
        .env("CONTENT_LENGTH", body.len().to_string())
137 +
        .env("GIT_CONFIG_NOSYSTEM", "1")
138 +
        .env("HOME", &root)
139 +
        .stdin(std::process::Stdio::piped())
140 +
        .stdout(std::process::Stdio::piped())
141 +
        .stderr(std::process::Stdio::piped());
142 +
    if let Some(enc) = headers.get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok()) {
143 +
        cmd.env("HTTP_CONTENT_ENCODING", enc);
144 +
    }
145 +
    if let Some(p) = headers.get("git-protocol").and_then(|v| v.to_str().ok()) {
146 +
        cmd.env("GIT_PROTOCOL", p);
147 +
    }
148 +
    let mut child = match cmd.spawn() {
149 +
        Ok(c) => c,
150 +
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("git http-backend: {e}")).into_response(),
151 +
    };
152 +
    if let Some(mut stdin) = child.stdin.take() {
153 +
        let _ = stdin.write_all(&body).await;
154 +
    }
155 +
    let out = match child.wait_with_output().await {
156 +
        Ok(o) => o,
157 +
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
158 +
    };
159 +
    cgi_response(&out.stdout)
160 +
}
161 +
162 +
/// Turn CGI output (headers, blank line, body) into a response.
163 +
fn cgi_response(raw: &[u8]) -> Response {
164 +
    let split =
165 +
        raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| (i, 4)).or_else(|| raw.windows(2).position(|w| w == b"\n\n").map(|i| (i, 2)));
166 +
    let Some((at, sep)) = split else { return (StatusCode::BAD_GATEWAY, "malformed CGI output").into_response() };
167 +
    let head = String::from_utf8_lossy(&raw[..at]);
168 +
    let body = raw[at + sep..].to_vec();
169 +
    let mut status = StatusCode::OK;
170 +
    let mut builder = Response::builder();
171 +
    for line in head.lines() {
172 +
        let Some((k, v)) = line.split_once(':') else { continue };
173 +
        let (k, v) = (k.trim(), v.trim());
174 +
        if k.eq_ignore_ascii_case("status") {
175 +
            if let Some(code) = v.split_whitespace().next().and_then(|c| c.parse::<u16>().ok()) {
176 +
                status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK);
177 +
            }
178 +
        } else {
179 +
            builder = builder.header(k, v);
180 +
        }
181 +
    }
182 +
    builder.status(status).body(axum::body::Body::from(body)).unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
183 +
}
184 +
185 +
#[cfg(test)]
186 +
mod tests {
187 +
    use super::*;
188 +
189 +
    #[test]
190 +
    fn base64_and_cgi() {
191 +
        assert_eq!(base64_decode("dXNlcjpzM2NyZXQ=").unwrap(), b"user:s3cret");
192 +
        let r = cgi_response(b"Status: 404 Not Found\r\nContent-Type: text/plain\r\n\r\nnope");
193 +
        assert_eq!(r.status(), StatusCode::NOT_FOUND);
194 +
    }
195 +
}
added crates/hub/src/repos/archive.rs +115 −0
1 +
//! Source archives of a state (hub spec §9): `.tar.zst` and `.zip` of the
2 +
//! materialised tree, cached by state hash since a state never changes.
3 +
4 +
use super::cache::Loaded;
5 +
use oxy_core::objects::NodeKind;
6 +
use oxy_core::repo::Repo;
7 +
use oxy_core::state::FileContent;
8 +
use std::io::Write;
9 +
use std::path::{Path, PathBuf};
10 +
11 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12 +
pub enum Format {
13 +
    TarZst,
14 +
    Zip,
15 +
}
16 +
17 +
impl Format {
18 +
    pub fn parse(ext: &str) -> Option<Format> {
19 +
        match ext {
20 +
            "tar.zst" => Some(Format::TarZst),
21 +
            "zip" => Some(Format::Zip),
22 +
            _ => None,
23 +
        }
24 +
    }
25 +
26 +
    pub fn ext(self) -> &'static str {
27 +
        match self {
28 +
            Format::TarZst => "tar.zst",
29 +
            Format::Zip => "zip",
30 +
        }
31 +
    }
32 +
33 +
    pub fn mime(self) -> &'static str {
34 +
        match self {
35 +
            Format::TarZst => "application/zstd",
36 +
            Format::Zip => "application/zip",
37 +
        }
38 +
    }
39 +
}
40 +
41 +
/// One archive member: path, bytes, executable, symlink.
42 +
type Member = (String, Vec<u8>, bool, bool);
43 +
44 +
/// Every regular file and symlink of the state.
45 +
fn entries(repo: &Repo, loaded: &Loaded) -> anyhow::Result<Vec<Member>> {
46 +
    let mut out = Vec::new();
47 +
    for (path, f) in &loaded.snapshot.files {
48 +
        let bytes = match (&f.kind, &f.content) {
49 +
            (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
50 +
            (_, FileContent::Text { bytes, .. }) => bytes.clone(),
51 +
            (_, FileContent::Opaque { blobs }) => match blobs.first() {
52 +
                Some(b) => repo.read_blob(b)?,
53 +
                None => vec![],
54 +
            },
55 +
        };
56 +
        out.push((path.clone(), bytes, f.mode.exec, f.kind == NodeKind::Symlink));
57 +
    }
58 +
    Ok(out)
59 +
}
60 +
61 +
/// Write the archive of `loaded` under `prefix/` to `dest` (atomically).
62 +
pub fn build(repo: &Repo, loaded: &Loaded, prefix: &str, format: Format, dest: &Path) -> anyhow::Result<()> {
63 +
    if let Some(dir) = dest.parent() {
64 +
        std::fs::create_dir_all(dir)?;
65 +
    }
66 +
    let tmp = dest.with_extension("partial");
67 +
    let files = entries(repo, loaded)?;
68 +
    match format {
69 +
        Format::TarZst => {
70 +
            let out = std::fs::File::create(&tmp)?;
71 +
            let enc = zstd::Encoder::new(out, 19)?;
72 +
            let mut tar = tar::Builder::new(enc);
73 +
            for (path, bytes, exec, symlink) in &files {
74 +
                let name = format!("{prefix}/{path}");
75 +
                let mut h = tar::Header::new_gnu();
76 +
                h.set_mtime(0);
77 +
                if *symlink {
78 +
                    h.set_entry_type(tar::EntryType::Symlink);
79 +
                    h.set_size(0);
80 +
                    h.set_mode(0o777);
81 +
                    tar.append_link(&mut h, &name, String::from_utf8_lossy(bytes).as_ref())?;
82 +
                } else {
83 +
                    h.set_size(bytes.len() as u64);
84 +
                    h.set_mode(if *exec { 0o755 } else { 0o644 });
85 +
                    tar.append_data(&mut h, &name, bytes.as_slice())?;
86 +
                }
87 +
            }
88 +
            tar.into_inner()?.finish()?.flush()?;
89 +
        }
90 +
        Format::Zip => {
91 +
            let out = std::fs::File::create(&tmp)?;
92 +
            let mut zip = zip::ZipWriter::new(out);
93 +
            for (path, bytes, exec, symlink) in &files {
94 +
                let name = format!("{prefix}/{path}");
95 +
                let opts = zip::write::SimpleFileOptions::default()
96 +
                    .compression_method(zip::CompressionMethod::Deflated)
97 +
                    .unix_permissions(if *exec { 0o755 } else { 0o644 });
98 +
                if *symlink {
99 +
                    zip.add_symlink(name, String::from_utf8_lossy(bytes).as_ref(), opts)?;
100 +
                } else {
101 +
                    zip.start_file(name, opts)?;
102 +
                    zip.write_all(bytes)?;
103 +
                }
104 +
            }
105 +
            zip.finish()?.flush()?;
106 +
        }
107 +
    }
108 +
    std::fs::rename(&tmp, dest)?;
109 +
    Ok(())
110 +
}
111 +
112 +
/// Where the cached archive of a state lives.
113 +
pub fn cache_path(data: &Path, state: &str, prefix: &str, format: Format) -> PathBuf {
114 +
    data.join("cache").join("archives").join(format!("{state}-{prefix}.{}", format.ext()))
115 +
}
added crates/hub/src/repos/search.rs +100 −0
1 +
//! Code search (hub spec §11): substring or regular expression over the
2 +
//! text files of one state, with a time budget and a result cap. Results
3 +
//! are cached by state hash and query, since a state never changes.
4 +
5 +
use super::cache::Loaded;
6 +
use oxy_core::state::FileContent;
7 +
use std::collections::HashMap;
8 +
use std::sync::Mutex;
9 +
use std::time::{Duration, Instant};
10 +
11 +
#[derive(Clone, Debug, PartialEq, Eq)]
12 +
pub struct Hit {
13 +
    pub path: String,
14 +
    /// 1-based; 0 for a match on the path itself.
15 +
    pub line: usize,
16 +
    pub text: String,
17 +
}
18 +
19 +
#[derive(Clone, Debug, Default)]
20 +
pub struct Results {
21 +
    pub hits: Vec<Hit>,
22 +
    pub files: usize,
23 +
    /// The budget or the cap stopped the search early.
24 +
    pub truncated: bool,
25 +
}
26 +
27 +
pub struct Query<'a> {
28 +
    pub text: &'a str,
29 +
    pub regex: bool,
30 +
    pub case_sensitive: bool,
31 +
    /// Only paths starting with this.
32 +
    pub path_prefix: &'a str,
33 +
}
34 +
35 +
pub const MAX_HITS: usize = 300;
36 +
pub const BUDGET: Duration = Duration::from_secs(2);
37 +
38 +
pub fn run(loaded: &Loaded, q: &Query<'_>) -> Result<Results, String> {
39 +
    let pattern = if q.regex { q.text.to_string() } else { regex::escape(q.text) };
40 +
    let re = regex::RegexBuilder::new(&pattern)
41 +
        .case_insensitive(!q.case_sensitive)
42 +
        .size_limit(1 << 20)
43 +
        .build()
44 +
        .map_err(|e| format!("not a valid regular expression: {e}"))?;
45 +
    let started = Instant::now();
46 +
    let mut out = Results::default();
47 +
    for (path, f) in &loaded.snapshot.files {
48 +
        if !path.starts_with(q.path_prefix) {
49 +
            continue;
50 +
        }
51 +
        if started.elapsed() > BUDGET || out.hits.len() >= MAX_HITS {
52 +
            out.truncated = true;
53 +
            break;
54 +
        }
55 +
        let mut found = false;
56 +
        if re.is_match(path) {
57 +
            out.hits.push(Hit { path: path.clone(), line: 0, text: String::new() });
58 +
            found = true;
59 +
        }
60 +
        let FileContent::Text { bytes, .. } = &f.content else { continue };
61 +
        if bytes.len() > 1 << 20 || bytes.contains(&0) {
62 +
            continue;
63 +
        }
64 +
        let text = String::from_utf8_lossy(bytes);
65 +
        for (i, line) in text.lines().enumerate() {
66 +
            if re.is_match(line) {
67 +
                out.hits.push(Hit { path: path.clone(), line: i + 1, text: line.chars().take(300).collect() });
68 +
                found = true;
69 +
                if out.hits.len() >= MAX_HITS {
70 +
                    out.truncated = true;
71 +
                    break;
72 +
                }
73 +
            }
74 +
        }
75 +
        if found {
76 +
            out.files += 1;
77 +
        }
78 +
    }
79 +
    Ok(out)
80 +
}
81 +
82 +
/// A small cache of results by (state, query).
83 +
#[derive(Default)]
84 +
pub struct SearchCache {
85 +
    inner: Mutex<HashMap<String, Results>>,
86 +
}
87 +
88 +
impl SearchCache {
89 +
    pub fn get(&self, key: &str) -> Option<Results> {
90 +
        self.inner.lock().unwrap().get(key).cloned()
91 +
    }
92 +
93 +
    pub fn put(&self, key: String, r: Results) {
94 +
        let mut m = self.inner.lock().unwrap();
95 +
        if m.len() > 256 {
96 +
            m.clear();
97 +
        }
98 +
        m.insert(key, r);
99 +
    }
100 +
}
added crates/hub/src/web/releases.rs +491 −0
1 +
//! Releases (hub spec §9): notes and assets on a tag, generated notes from
2 +
//! the changes since the previous release, source archives of any state.
3 +
4 +
use super::settings::{csrf, require};
5 +
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
6 +
use crate::auth::{Access, Viewer};
7 +
use crate::db::{self, accounts, releases as rdb};
8 +
use crate::repos::archive::{self, Format};
9 +
use crate::repos::{view, RepoName};
10 +
use crate::Shared;
11 +
use askama::Template;
12 +
use axum::extract::{Form, Multipart, Path, Query, State};
13 +
use axum::http::header;
14 +
use axum::response::{IntoResponse, Redirect, Response};
15 +
use oxy_core::hash::ChangeHash;
16 +
use serde::Deserialize;
17 +
use std::collections::HashMap;
18 +
19 +
/// A release as the pages and the API show it.
20 +
pub struct ReleaseView {
21 +
    pub r: rdb::Release,
22 +
    pub notes_html: String,
23 +
    pub assets: Vec<rdb::Asset>,
24 +
    pub heads: Vec<String>,
25 +
    pub signer: Option<String>,
26 +
    pub latest: bool,
27 +
}
28 +
29 +
async fn views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<ReleaseView>, sqlx::Error> {
30 +
    let list = rdb::list(&app.db, row.id).await?;
31 +
    let latest = rdb::latest(&app.db, row.id).await?.map(|r| r.id);
32 +
    let tags: HashMap<String, (Vec<String>, Option<String>)> = app
33 +
        .repos
34 +
        .read(repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| (t.name, (t.heads, t.signer))).collect()))
35 +
        .await
36 +
        .unwrap_or_default();
37 +
    let mut out = Vec::new();
38 +
    for r in list {
39 +
        let assets = rdb::assets(&app.db, r.id).await?;
40 +
        let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default();
41 +
        let notes_html = super::issues::markdown(app, row.id, &format!("/{}", row.slug()), &r.notes).await;
42 +
        out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer });
43 +
    }
44 +
    Ok(out)
45 +
}
46 +
47 +
#[derive(Template)]
48 +
#[template(path = "releases.html")]
49 +
pub struct ReleasesPage {
50 +
    shell: Shell,
51 +
    repo: RepoShell,
52 +
    releases: Vec<ReleaseView>,
53 +
    unreleased: Vec<String>,
54 +
    one: bool,
55 +
}
56 +
57 +
pub async fn list(
58 +
    State(app): State<Shared>,
59 +
    viewer: Viewer,
60 +
    Path((owner, name)): Path<(String, String)>,
61 +
) -> Result<Page<ReleasesPage>, Error> {
62 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
63 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
64 +
    let releases = views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
65 +
    let tags: Vec<String> =
66 +
        app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default();
67 +
    let unreleased = tags.into_iter().filter(|t| !releases.iter().any(|r| &r.r.tag == t)).collect();
68 +
    Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased, one: false }))
69 +
}
70 +
71 +
pub async fn show(
72 +
    State(app): State<Shared>,
73 +
    viewer: Viewer,
74 +
    Path((owner, name, tag)): Path<(String, String, String)>,
75 +
) -> Result<Page<ReleasesPage>, Error> {
76 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/tag/{tag}"), &viewer);
77 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
78 +
    let releases: Vec<ReleaseView> =
79 +
        views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().filter(|r| r.r.tag == tag).collect();
80 +
    if releases.is_empty() {
81 +
        return Err(Error::not_found(shell));
82 +
    }
83 +
    Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased: vec![], one: true }))
84 +
}
85 +
86 +
/// Notes drafted from the changes in `tag`'s state that the previous
87 +
/// release's state lacks: one line per change, newest first.
88 +
async fn generated_notes(app: &Shared, repo: &RepoName, repo_id: i64, tag: &str) -> String {
89 +
    let previous = rdb::list(&app.db, repo_id).await.unwrap_or_default().into_iter().find(|r| r.tag != tag).map(|r| r.tag);
90 +
    let tag = tag.to_string();
91 +
    app.repos
92 +
        .read(repo, move |r, cache| {
93 +
            let Some(heads) = r.tag_heads(&tag)? else { return Ok(String::new()) };
94 +
            let now = cache.load(r, &heads)?;
95 +
            let before: Vec<ChangeHash> = match &previous {
96 +
                Some(p) => r.tag_heads(p)?.unwrap_or_default(),
97 +
                None => vec![],
98 +
            };
99 +
            let then = cache.load(r, &before)?;
100 +
            let mut added: Vec<_> = now
101 +
                .state
102 +
                .changes()
103 +
                .filter(|(h, _)| !then.state.contains(h))
104 +
                .map(|(h, m)| (m.hlc, *h, m.message.clone(), m.author))
105 +
                .collect();
106 +
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
107 +
            let mut out = match &previous {
108 +
                Some(p) => format!("Changes since {p}:\n\n"),
109 +
                None => "Changes:\n\n".to_string(),
110 +
            };
111 +
            for (_, h, message, author) in added {
112 +
                let who = view::author(r, &author, &message).name;
113 +
                out.push_str(&format!("- {} ({}, {})\n", message.lines().next().unwrap_or(""), &h.to_hex()[..12], who));
114 +
            }
115 +
            Ok(out)
116 +
        })
117 +
        .await
118 +
        .unwrap_or_default()
119 +
}
120 +
121 +
#[derive(Template)]
122 +
#[template(path = "release_new.html")]
123 +
pub struct NewReleasePage {
124 +
    shell: Shell,
125 +
    repo: RepoShell,
126 +
    tags: Vec<String>,
127 +
    tag: String,
128 +
    notes: String,
129 +
    error: Option<String>,
130 +
}
131 +
132 +
#[derive(Deserialize)]
133 +
pub struct NewQuery {
134 +
    tag: Option<String>,
135 +
}
136 +
137 +
pub async fn new_form(
138 +
    State(app): State<Shared>,
139 +
    viewer: Viewer,
140 +
    Path((owner, name)): Path<(String, String)>,
141 +
    Query(q): Query<NewQuery>,
142 +
) -> Result<Page<NewReleasePage>, Error> {
143 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer);
144 +
    require(&viewer, &shell)?;
145 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
146 +
    let tags: Vec<String> = app
147 +
        .repos
148 +
        .read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect()))
149 +
        .await
150 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
151 +
    let tag = q.tag.filter(|t| tags.contains(t)).or_else(|| tags.first().cloned()).unwrap_or_default();
152 +
    let notes = if tag.is_empty() { String::new() } else { generated_notes(&app, &repo, row.id, &tag).await };
153 +
    Ok(Page(NewReleasePage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), tags, tag, notes, error: None }))
154 +
}
155 +
156 +
#[derive(Deserialize)]
157 +
pub struct NewForm {
158 +
    #[serde(rename = "_csrf")]
159 +
    csrf_token: String,
160 +
    tag: String,
161 +
    title: String,
162 +
    #[serde(default)]
163 +
    notes: String,
164 +
    #[serde(default)]
165 +
    prerelease: Option<String>,
166 +
}
167 +
168 +
/// Create a release for an existing tag; shared by the form and the API.
169 +
#[allow(clippy::too_many_arguments)]
170 +
pub async fn create_release(
171 +
    app: &Shared,
172 +
    repo: &RepoName,
173 +
    row: &db::repos::RepoRow,
174 +
    me: &accounts::Account,
175 +
    tag: &str,
176 +
    title: &str,
177 +
    notes: Option<String>,
178 +
    prerelease: bool,
179 +
) -> Result<rdb::Release, String> {
180 +
    let exists = app
181 +
        .repos
182 +
        .read(repo, {
183 +
            let t = tag.to_string();
184 +
            move |r, _| Ok(r.tag_heads(&t)?.is_some())
185 +
        })
186 +
        .await
187 +
        .map_err(|e| e.to_string())?;
188 +
    if !exists {
189 +
        return Err(format!("there is no tag {tag}; create it with oxy tag create and push it"));
190 +
    }
191 +
    if rdb::get(&app.db, row.id, tag).await.map_err(|e| e.to_string())?.is_some() {
192 +
        return Err(format!("{tag} already has a release"));
193 +
    }
194 +
    let notes = match notes {
195 +
        Some(n) => n,
196 +
        None => generated_notes(app, repo, row.id, tag).await,
197 +
    };
198 +
    let title = if title.trim().is_empty() { tag.to_string() } else { title.trim().to_string() };
199 +
    let r = rdb::create(&app.db, row.id, tag, &title, notes.trim(), prerelease, Some((me.id, &me.username)))
200 +
        .await
201 +
        .map_err(|e| e.to_string())?;
202 +
    accounts::audit(&app.db, Some(me), "release.published", &format!("{}@{tag}", row.slug()), "").await.ok();
203 +
    crate::hooks::emit(
204 +
        app,
205 +
        row,
206 +
        "release.published",
207 +
        &me.username,
208 +
        serde_json::json!({ "tag": tag, "title": title, "prerelease": prerelease }),
209 +
    )
210 +
    .await;
211 +
    crate::notify::deliver(
212 +
        app,
213 +
        crate::notify::Event {
214 +
            kind: "release",
215 +
            title: format!("{} released {title}", row.slug()),
216 +
            url: format!("/{}/releases/tag/{tag}", row.slug()),
217 +
            actor: me,
218 +
            repo_id: row.id,
219 +
            thread: None,
220 +
            mention_text: "",
221 +
            direct: vec![],
222 +
        },
223 +
    )
224 +
    .await;
225 +
    Ok(r)
226 +
}
227 +
228 +
pub async fn create(
229 +
    State(app): State<Shared>,
230 +
    viewer: Viewer,
231 +
    Path((owner, name)): Path<(String, String)>,
232 +
    Form(f): Form<NewForm>,
233 +
) -> Result<Response, Error> {
234 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer);
235 +
    let me = require(&viewer, &shell)?;
236 +
    csrf(&viewer, &f.csrf_token, &shell)?;
237 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
238 +
    match create_release(&app, &repo, &row, &me.account, &f.tag, &f.title, Some(f.notes.clone()), f.prerelease.is_some()).await {
239 +
        Ok(r) => Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()),
240 +
        Err(msg) => {
241 +
            let tags = app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default();
242 +
            Ok(Page(NewReleasePage {
243 +
                shell,
244 +
                repo: RepoShell::new(&app, &row, "releases").with_role(role),
245 +
                tags,
246 +
                tag: f.tag,
247 +
                notes: f.notes,
248 +
                error: Some(msg),
249 +
            })
250 +
            .into_response())
251 +
        }
252 +
    }
253 +
}
254 +
255 +
#[derive(Deserialize)]
256 +
pub struct EditForm {
257 +
    #[serde(rename = "_csrf")]
258 +
    csrf_token: String,
259 +
    #[serde(default)]
260 +
    title: String,
261 +
    #[serde(default)]
262 +
    notes: String,
263 +
    #[serde(default)]
264 +
    prerelease: Option<String>,
265 +
    #[serde(default)]
266 +
    action: String,
267 +
    #[serde(default)]
268 +
    asset: Option<i64>,
269 +
}
270 +
271 +
pub async fn edit(
272 +
    State(app): State<Shared>,
273 +
    viewer: Viewer,
274 +
    Path((owner, name, id)): Path<(String, String, i64)>,
275 +
    Form(f): Form<EditForm>,
276 +
) -> Result<Response, Error> {
277 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
278 +
    let me = require(&viewer, &shell)?;
279 +
    csrf(&viewer, &f.csrf_token, &shell)?;
280 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
281 +
    let e = |e| Error::from_db(shell.clone(), e);
282 +
    let Some(r) = rdb::list(&app.db, row.id).await.map_err(e)?.into_iter().find(|r| r.id == id) else {
283 +
        return Err(Error::not_found(shell));
284 +
    };
285 +
    match f.action.as_str() {
286 +
        "delete" => {
287 +
            let assets = rdb::assets(&app.db, r.id).await.map_err(e)?;
288 +
            rdb::delete(&app.db, r.id).await.map_err(e)?;
289 +
            for a in assets {
290 +
                remove_blob_if_unused(&app, &a.hash).await;
291 +
            }
292 +
            accounts::audit(&app.db, Some(&me.account), "release.deleted", &format!("{owner}/{name}@{}", r.tag), "").await.ok();
293 +
            return Ok(Redirect::to(&format!("/{owner}/{name}/releases")).into_response());
294 +
        }
295 +
        "delete_asset" => {
296 +
            if let Some(a) = rdb::assets(&app.db, r.id).await.map_err(e)?.into_iter().find(|a| Some(a.id) == f.asset) {
297 +
                rdb::delete_asset(&app.db, a.id).await.map_err(e)?;
298 +
                remove_blob_if_unused(&app, &a.hash).await;
299 +
            }
300 +
        }
301 +
        _ => rdb::edit(
302 +
            &app.db,
303 +
            r.id,
304 +
            if f.title.trim().is_empty() { &r.tag } else { f.title.trim() },
305 +
            f.notes.trim(),
306 +
            f.prerelease.is_some(),
307 +
        )
308 +
        .await
309 +
        .map_err(e)?,
310 +
    }
311 +
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
312 +
}
313 +
314 +
fn blob_path(app: &Shared, hash: &str) -> std::path::PathBuf {
315 +
    app.config.data.join("assets").join(&hash[..2]).join(hash)
316 +
}
317 +
318 +
async fn remove_blob_if_unused(app: &Shared, hash: &str) {
319 +
    if !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) {
320 +
        tokio::fs::remove_file(blob_path(app, hash)).await.ok();
321 +
    }
322 +
}
323 +
324 +
/// Store an uploaded asset by content and attach it to a release.
325 +
pub async fn store_asset(app: &Shared, release_id: i64, name: &str, content_type: &str, bytes: &[u8]) -> Result<rdb::Asset, String> {
326 +
    let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
327 +
    if name.is_empty() || name.starts_with('.') || name.len() > 200 {
328 +
        return Err("give the asset a file name".into());
329 +
    }
330 +
    if bytes.len() as u64 > app.config.limits.asset_limit.0 {
331 +
        return Err("the asset is larger than this hub allows".into());
332 +
    }
333 +
    let hash = hex::encode(blake3::hash(bytes).as_bytes());
334 +
    let path = blob_path(app, &hash);
335 +
    if !path.exists() {
336 +
        tokio::fs::create_dir_all(path.parent().unwrap()).await.map_err(|e| e.to_string())?;
337 +
        let tmp = path.with_extension("partial");
338 +
        tokio::fs::write(&tmp, bytes).await.map_err(|e| e.to_string())?;
339 +
        tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?;
340 +
    }
341 +
    let ct = if content_type.is_empty() { "application/octet-stream" } else { content_type };
342 +
    rdb::add_asset(&app.db, release_id, name, bytes.len() as i64, &hash, ct).await.map_err(|e| e.to_string())
343 +
}
344 +
345 +
pub async fn upload(
346 +
    State(app): State<Shared>,
347 +
    viewer: Viewer,
348 +
    Path((owner, name, id)): Path<(String, String, i64)>,
349 +
    mut multipart: Multipart,
350 +
) -> Result<Response, Error> {
351 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
352 +
    require(&viewer, &shell)?;
353 +
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
354 +
    let Some(r) = rdb::list(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().find(|r| r.id == id) else {
355 +
        return Err(Error::not_found(shell));
356 +
    };
357 +
    let mut token = String::new();
358 +
    let mut files: Vec<(String, String, Vec<u8>)> = vec![];
359 +
    while let Ok(Some(field)) = multipart.next_field().await {
360 +
        match field.name() {
361 +
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
362 +
            Some("asset") => {
363 +
                let fname = field.file_name().unwrap_or("").to_string();
364 +
                let ct = field.content_type().unwrap_or("").to_string();
365 +
                let bytes = field.bytes().await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
366 +
                if !fname.is_empty() {
367 +
                    files.push((fname, ct, bytes.to_vec()));
368 +
                }
369 +
            }
370 +
            _ => {}
371 +
        }
372 +
    }
373 +
    csrf(&viewer, &token, &shell)?;
374 +
    for (fname, ct, bytes) in files {
375 +
        store_asset(&app, r.id, &fname, &ct, &bytes).await.map_err(|m| Error::bad(shell.clone(), m))?;
376 +
    }
377 +
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
378 +
}
379 +
380 +
pub async fn download(
381 +
    State(app): State<Shared>,
382 +
    viewer: Viewer,
383 +
    Path((owner, name, id, file)): Path<(String, String, i64, String)>,
384 +
) -> Result<Response, Error> {
385 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
386 +
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
387 +
    let e = |e| Error::from_db(shell.clone(), e);
388 +
    let asset: Option<rdb::Asset> =
389 +
        sqlx::query_as("SELECT a.* FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE a.id = ? AND r.repo_id = ?")
390 +
            .bind(id)
391 +
            .bind(row.id)
392 +
            .fetch_optional(&app.db)
393 +
            .await
394 +
            .map_err(e)?;
395 +
    let Some(a) = asset.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) };
396 +
    let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?;
397 +
    rdb::count_download(&app.db, a.id).await.ok();
398 +
    Ok((
399 +
        [
400 +
            (header::CONTENT_TYPE, a.content_type.clone()),
401 +
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", a.name.replace('"', ""))),
402 +
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
403 +
            (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()),
404 +
        ],
405 +
        bytes,
406 +
    )
407 +
        .into_response())
408 +
}
409 +
410 +
#[derive(Deserialize)]
411 +
pub struct ArchiveQuery {
412 +
    #[serde(rename = "ref")]
413 +
    r: String,
414 +
    format: String,
415 +
}
416 +
417 +
/// `GET /{owner}/{name}/archive?ref=v1.0&format=tar.zst`: built once per
418 +
/// state and served from the cache after.
419 +
pub async fn source_archive(
420 +
    State(app): State<Shared>,
421 +
    viewer: Viewer,
422 +
    Path((owner, name)): Path<(String, String)>,
423 +
    Query(q): Query<ArchiveQuery>,
424 +
) -> Result<Response, Error> {
425 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/archive"), &viewer);
426 +
    let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
427 +
    let format = Format::parse(&q.format).ok_or_else(|| Error::bad(shell.clone(), "format is tar.zst or zip"))?;
428 +
    let label: String = q.r.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect();
429 +
    let prefix = format!("{name}-{}", label.trim_matches('-'));
430 +
    let data = app.config.data.clone();
431 +
    let spec = q.r.clone();
432 +
    let p = prefix.clone();
433 +
    let path = app
434 +
        .repos
435 +
        .read(&repo, move |r, cache| {
436 +
            let target = view::resolve(r, cache, &spec)?;
437 +
            let loaded = cache.load(r, &target.heads)?;
438 +
            let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex();
439 +
            let dest = archive::cache_path(&data, &state, &p, format);
440 +
            if !dest.exists() {
441 +
                archive::build(r, &loaded, &p, format, &dest)
442 +
                    .map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
443 +
            }
444 +
            Ok(dest)
445 +
        })
446 +
        .await
447 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
448 +
    let bytes = tokio::fs::read(&path).await.map_err(|_| Error::not_found(shell.clone()))?;
449 +
    Ok((
450 +
        [
451 +
            (header::CONTENT_TYPE, format.mime().to_string()),
452 +
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{prefix}.{}\"", format.ext())),
453 +
        ],
454 +
        bytes,
455 +
    )
456 +
        .into_response())
457 +
}
458 +
459 +
/// API shape of a release.
460 +
pub fn to_api(app: &Shared, row: &db::repos::RepoRow, v: &ReleaseView) -> hub_api::Release {
461 +
    let base = format!("{}/{}", app.config.base_url(), row.slug());
462 +
    hub_api::Release {
463 +
        tag: v.r.tag.clone(),
464 +
        title: v.r.title.clone(),
465 +
        notes: v.r.notes.clone(),
466 +
        prerelease: v.r.prerelease,
467 +
        latest: v.latest,
468 +
        author: v.r.author.clone(),
469 +
        heads: v.heads.clone(),
470 +
        signer: v.signer.clone(),
471 +
        assets: v
472 +
            .assets
473 +
            .iter()
474 +
            .map(|a| hub_api::ReleaseAsset {
475 +
                name: a.name.clone(),
476 +
                size: a.size,
477 +
                hash: a.hash.clone(),
478 +
                content_type: a.content_type.clone(),
479 +
                downloads: a.downloads,
480 +
                url: format!("{base}/asset/{}/{}", a.id, a.name),
481 +
            })
482 +
            .collect(),
483 +
        archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(),
484 +
        created_ms: v.r.created_ms,
485 +
        url: format!("{base}/releases/tag/{}", v.r.tag),
486 +
    }
487 +
}
488 +
489 +
pub async fn api_views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<hub_api::Release>, sqlx::Error> {
490 +
    Ok(views(app, repo, row).await?.iter().map(|v| to_api(app, row, v)).collect())
491 +
}
added crates/hub/tests/releases.rs +327 −0
1 +
//! The rest of milestone 0.6: releases with generated notes, assets and
2 +
//! source archives; the read-only Git mirror cloned with real git; code
3 +
//! search; anchored proposal comments through the API.
4 +
5 +
use axum::body::Body;
6 +
use axum::http::{header, Request, StatusCode};
7 +
use http_body_util::BodyExt;
8 +
use oxy_core::repo::{IdentityScope, Repo};
9 +
use oxygenhub::config::Config;
10 +
use oxygenhub::db;
11 +
use oxygenhub::repos::RepoName;
12 +
use oxygenhub::Shared;
13 +
use std::io::Read;
14 +
use std::path::Path;
15 +
use tower::ServiceExt;
16 +
17 +
fn hermetic() {
18 +
    static ONCE: std::sync::Once = std::sync::Once::new();
19 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
20 +
    ONCE.call_once(|| {
21 +
        let d = tempfile::tempdir().unwrap();
22 +
        std::env::set_var("OXY_HOME", d.path());
23 +
        DIR.set(d).ok();
24 +
    });
25 +
}
26 +
27 +
fn write(root: &Path, rel: &str, content: &[u8]) {
28 +
    let p = root.join(rel);
29 +
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
30 +
    std::fs::write(p, content).unwrap();
31 +
}
32 +
33 +
struct Res {
34 +
    status: StatusCode,
35 +
    body: Vec<u8>,
36 +
    cookie: Option<String>,
37 +
    location: Option<String>,
38 +
}
39 +
40 +
impl Res {
41 +
    fn text(&self) -> String {
42 +
        String::from_utf8_lossy(&self.body).into_owned()
43 +
    }
44 +
}
45 +
46 +
async fn send(
47 +
    app: &Shared,
48 +
    method: &str,
49 +
    path: &str,
50 +
    cookie: Option<&str>,
51 +
    bearer: Option<&str>,
52 +
    ctype: Option<&str>,
53 +
    body: Vec<u8>,
54 +
) -> Res {
55 +
    let mut req = Request::builder().method(method).uri(path);
56 +
    if let Some(c) = cookie {
57 +
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
58 +
    }
59 +
    if let Some(b) = bearer {
60 +
        req = req.header(header::AUTHORIZATION, format!("Bearer {b}"));
61 +
    }
62 +
    if let Some(t) = ctype {
63 +
        req = req.header(header::CONTENT_TYPE, t);
64 +
    }
65 +
    let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(body)).unwrap()).await.unwrap();
66 +
    let status = res.status();
67 +
    let cookie = res
68 +
        .headers()
69 +
        .get(header::SET_COOKIE)
70 +
        .and_then(|v| v.to_str().ok())
71 +
        .and_then(|v| v.split(';').next())
72 +
        .and_then(|kv| kv.split_once('='))
73 +
        .map(|(_, v)| v.to_string());
74 +
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
75 +
    let body = res.into_body().collect().await.unwrap().to_bytes().to_vec();
76 +
    Res { status, body, cookie, location }
77 +
}
78 +
79 +
async fn form(app: &Shared, path: &str, cookie: &str, f: &str) -> Res {
80 +
    send(app, "POST", path, Some(cookie), None, Some("application/x-www-form-urlencoded"), f.as_bytes().to_vec()).await
81 +
}
82 +
83 +
fn csrf_of(page: &str) -> String {
84 +
    let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len();
85 +
    page[i..].split('"').next().unwrap().to_string()
86 +
}
87 +
88 +
#[tokio::test(flavor = "multi_thread")]
89 +
async fn releases_mirror_search_and_anchors() {
90 +
    hermetic();
91 +
    let dir = tempfile::tempdir().unwrap();
92 +
    let mut config = Config::load(dir.path()).unwrap();
93 +
    config.auth.push_tokens = vec!["site".into()];
94 +
    config.registration.mode = "open".into();
95 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
96 +
    let port = listener.local_addr().unwrap().port();
97 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
98 +
    let app = oxygenhub::build(config).await.unwrap();
99 +
    let router = oxygenhub::router(app.clone());
100 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
101 +
    let base = format!("http://127.0.0.1:{port}");
102 +
    let app = &app;
103 +
    let andy = send(
104 +
        app,
105 +
        "POST",
106 +
        "/register",
107 +
        None,
108 +
        None,
109 +
        Some("application/x-www-form-urlencoded"),
110 +
        b"username=andy&email=a%40example.com&password=correct-horse-battery".to_vec(),
111 +
    )
112 +
    .await
113 +
    .cookie
114 +
    .unwrap();
115 +
    let repo = RepoName::parse("andy/demo").unwrap();
116 +
    app.repos.create(&repo, None).unwrap();
117 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
118 +
119 +
    // Two signed changes, a signed tag on the first, and a second tag later.
120 +
    let work = tempfile::tempdir().unwrap();
121 +
    let mut client = Repo::init(work.path()).unwrap();
122 +
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
123 +
    client.config.set("core.sign", "change").unwrap();
124 +
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
125 +
    client.config.set("remote.origin.token", "site").unwrap();
126 +
    client.save_config().unwrap();
127 +
    write(work.path(), "src/main.rs", b"fn main() {\n    println!(\"hello flux\");\n}\n");
128 +
    write(work.path(), "run.sh", b"#!/bin/sh\necho run\n");
129 +
    std::fs::set_permissions(work.path().join("run.sh"), std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
130 +
    client.snapshot("t").unwrap();
131 +
    let c1 = client.commit("First light", &[]).unwrap();
132 +
    client.tag_create("v0.1", &[c1], "First release").unwrap();
133 +
    write(work.path(), "README.md", b"# Demo\n");
134 +
    client.snapshot("t").unwrap();
135 +
    let c2 = client.commit("Add a README", &[]).unwrap();
136 +
    client.tag_create("v0.2", &[c2], "Second").unwrap();
137 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
138 +
139 +
    // Releases: generated notes list the changes since the previous release.
140 +
    let page = send(app, "GET", "/andy/demo/releases/new?tag=v0.1", Some(&andy), None, None, vec![]).await.text();
141 +
    assert!(page.contains("First light"), "{page}");
142 +
    let csrf = csrf_of(&page);
143 +
    let r = form(app, "/andy/demo/releases/new", &andy, &format!("_csrf={csrf}&tag=v0.1&title=Zero+one&notes=First")).await;
144 +
    assert_eq!(r.location.as_deref(), Some("/andy/demo/releases/tag/v0.1"), "{}", r.text());
145 +
    let (st, v) = {
146 +
        let r = send(
147 +
            app,
148 +
            "POST",
149 +
            "/api/v1/repos/andy/demo/releases",
150 +
            None,
151 +
            Some("site"),
152 +
            Some("application/json"),
153 +
            br#"{"tag":"v0.2","title":"Zero two"}"#.to_vec(),
154 +
        )
155 +
        .await;
156 +
        (r.status, serde_json::from_slice::<serde_json::Value>(&r.body).unwrap())
157 +
    };
158 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
159 +
    assert!(
160 +
        v["notes"].as_str().unwrap().contains("Add a README") && !v["notes"].as_str().unwrap().contains("First light"),
161 +
        "notes since v0.1: {v}"
162 +
    );
163 +
    assert_eq!(v["latest"], true);
164 +
    assert_eq!(v["signer"], "Andy");
165 +
    let r = send(
166 +
        app,
167 +
        "POST",
168 +
        "/api/v1/repos/andy/demo/releases",
169 +
        None,
170 +
        Some("site"),
171 +
        Some("application/json"),
172 +
        br#"{"tag":"nope","title":"x"}"#.to_vec(),
173 +
    )
174 +
    .await;
175 +
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
176 +
177 +
    // An asset through the API; download it back; the page lists it.
178 +
    let r = send(
179 +
        app,
180 +
        "POST",
181 +
        "/api/v1/repos/andy/demo/releases/v0.2/assets?name=demo-linux.bin",
182 +
        None,
183 +
        Some("site"),
184 +
        Some("application/octet-stream"),
185 +
        b"\x7fELF binary".to_vec(),
186 +
    )
187 +
    .await;
188 +
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
189 +
    let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
190 +
    let url = v["assets"][0]["url"].as_str().unwrap().replace(&base, "");
191 +
    assert_eq!(v["assets"][0]["hash"], hex::encode(blake3::hash(b"\x7fELF binary").as_bytes()));
192 +
    let got = send(app, "GET", &url, None, None, None, vec![]).await;
193 +
    assert_eq!(got.body, b"\x7fELF binary");
194 +
    let list = send(app, "GET", "/andy/demo/releases", None, None, None, vec![]).await.text();
195 +
    assert!(list.contains("Zero two") && list.contains("demo-linux.bin") && list.contains("latest"), "{list}");
196 +
197 +
    // Source archives: tar.zst with the exec bit, and zip.
198 +
    let tz = send(app, "GET", "/andy/demo/archive?ref=v0.1&format=tar.zst", None, None, None, vec![]).await;
199 +
    assert_eq!(tz.status, StatusCode::OK);
200 +
    let mut tar = tar::Archive::new(zstd::Decoder::new(tz.body.as_slice()).unwrap());
201 +
    let mut seen = vec![];
202 +
    for e in tar.entries().unwrap() {
203 +
        let mut e = e.unwrap();
204 +
        let path = e.path().unwrap().to_string_lossy().into_owned();
205 +
        let mode = e.header().mode().unwrap();
206 +
        let mut s = String::new();
207 +
        e.read_to_string(&mut s).unwrap();
208 +
        seen.push((path, mode, s));
209 +
    }
210 +
    assert!(seen.iter().any(|(p, _, s)| p == "demo-v0.1/src/main.rs" && s.contains("hello flux")), "{seen:?}");
211 +
    assert!(seen.iter().any(|(p, m, _)| p == "demo-v0.1/run.sh" && m & 0o111 != 0), "exec bit kept: {seen:?}");
212 +
    assert!(!seen.iter().any(|(p, _, _)| p.ends_with("README.md")), "v0.1 predates the README");
213 +
    let zp = send(app, "GET", "/andy/demo/archive?ref=main&format=zip", None, None, None, vec![]).await;
214 +
    let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zp.body)).unwrap();
215 +
    let mut readme = String::new();
216 +
    zip.by_name("demo-main/README.md").unwrap().read_to_string(&mut readme).unwrap();
217 +
    assert_eq!(readme, "# Demo\n");
218 +
219 +
    // Code search: substring and regex, with path matches.
220 +
    let s = send(app, "GET", "/andy/demo/search?q=flux", None, None, None, vec![]).await.text();
221 +
    assert!(s.contains("src/main.rs:2"), "{s}");
222 +
    let s = send(app, "GET", "/andy/demo/search?q=print%5Cw%2B&re=1", None, None, None, vec![]).await.text();
223 +
    assert!(s.contains("src/main.rs:2"), "{s}");
224 +
    let s = send(app, "GET", "/andy/demo/search?q=%28&re=1", None, None, None, vec![]).await.text();
225 +
    assert!(s.contains("not a valid regular expression"), "{s}");
226 +
    assert!(send(app, "GET", "/andy/demo/search?q=README", None, None, None, vec![]).await.text().contains("path matches"));
227 +
228 +
    // The Git mirror: off by default, on from settings, cloned with git, push refused.
229 +
    assert_eq!(
230 +
        send(app, "GET", "/andy/demo.git/info/refs?service=git-upload-pack", None, None, None, vec![]).await.status,
231 +
        StatusCode::NOT_FOUND
232 +
    );
233 +
    let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None, None, vec![]).await.text();
234 +
    let r = form(app, "/andy/demo/settings/mirror", &andy, &format!("_csrf={}&enabled=1", csrf_of(&settings))).await;
235 +
    assert!(r.text().contains("mirror is on"), "{}", r.text());
236 +
    let clone_dir = tempfile::tempdir().unwrap();
237 +
    let url = format!("{base}/andy/demo.git");
238 +
    let target = clone_dir.path().join("demo");
239 +
    let out = tokio::task::spawn_blocking(move || {
240 +
        std::process::Command::new("git")
241 +
            .args(["clone", "-q", &url, target.to_str().unwrap()])
242 +
            .env("GIT_TERMINAL_PROMPT", "0")
243 +
            .output()
244 +
            .unwrap()
245 +
    })
246 +
    .await
247 +
    .unwrap();
248 +
    assert!(out.status.success(), "git clone: {}", String::from_utf8_lossy(&out.stderr));
249 +
    assert_eq!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap(), "# Demo\n");
250 +
    let log = std::process::Command::new("git")
251 +
        .args(["-C", clone_dir.path().join("demo").to_str().unwrap(), "log", "--format=%B"])
252 +
        .output()
253 +
        .unwrap();
254 +
    assert!(String::from_utf8_lossy(&log.stdout).contains("Add a README"));
255 +
    // A new push refreshes the mirror.
256 +
    write(work.path(), "README.md", b"# Demo\n\nMore.\n");
257 +
    client.snapshot("t").unwrap();
258 +
    client.commit("More README", &[]).unwrap();
259 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
260 +
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
261 +
    let d = clone_dir.path().join("demo");
262 +
    let pull = tokio::task::spawn_blocking(move || {
263 +
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "pull", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap()
264 +
    })
265 +
    .await
266 +
    .unwrap();
267 +
    assert!(pull.status.success(), "{}", String::from_utf8_lossy(&pull.stderr));
268 +
    assert!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap().contains("More."));
269 +
    let d = clone_dir.path().join("demo");
270 +
    let push = tokio::task::spawn_blocking(move || {
271 +
        std::fs::write(d.join("x.txt"), "x").unwrap();
272 +
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "add", "x.txt"]).output().unwrap();
273 +
        std::process::Command::new("git")
274 +
            .args(["-C", d.to_str().unwrap(), "-c", "user.name=x", "-c", "user.email=x@x", "commit", "-qm", "x"])
275 +
            .output()
276 +
            .unwrap();
277 +
        std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "push", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap()
278 +
    })
279 +
    .await
280 +
    .unwrap();
281 +
    assert!(!push.status.success(), "pushing to the mirror is refused");
282 +
283 +
    // Anchored comments through the API, at any line of the projected state.
284 +
    client.bookmark_create("feat", None).unwrap();
285 +
    client.bookmark_switch("feat", false).unwrap();
286 +
    write(work.path(), "src/main.rs", b"fn main() {\n    println!(\"hello flux\");\n    println!(\"again\");\n}\n");
287 +
    client.snapshot("t").unwrap();
288 +
    client.commit("Again", &[]).unwrap();
289 +
    assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok));
290 +
    let r = send(
291 +
        app,
292 +
        "POST",
293 +
        "/api/v1/repos/andy/demo/proposals",
294 +
        None,
295 +
        Some("site"),
296 +
        Some("application/json"),
297 +
        br#"{"title":"Again","target":"main","source":"feat"}"#.to_vec(),
298 +
    )
299 +
    .await;
300 +
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
301 +
    let number = serde_json::from_slice::<serde_json::Value>(&r.body).unwrap()["number"].as_i64().unwrap();
302 +
    let r = send(
303 +
        app,
304 +
        "POST",
305 +
        &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"),
306 +
        None,
307 +
        Some("site"),
308 +
        Some("application/json"),
309 +
        br#"{"body":"why twice?","path":"src/main.rs","line":1}"#.to_vec(),
310 +
    )
311 +
    .await;
312 +
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
313 +
    let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
314 +
    assert_eq!(v["path"], "src/main.rs");
315 +
    assert_eq!(v["anchor"]["change"], c1.to_hex(), "line 1 was written by the first change");
316 +
    let r = send(
317 +
        app,
318 +
        "POST",
319 +
        &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"),
320 +
        None,
321 +
        Some("site"),
322 +
        Some("application/json"),
323 +
        br#"{"body":"x","path":"src/main.rs","line":99}"#.to_vec(),
324 +
    )
325 +
    .await;
326 +
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
327 +
}
added templates/code_search.html +26 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Search {{ q }} · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %}
6 +
{% block mainclass %} class="wide"{% endblock %}
7 +
{% block content %}
8 +
<div class="head"><h1>Search code <small>on ◆ {{ r }}{% if !q.is_empty() %} · {{ results.hits.len() }} matches in {{ results.files|plural("file", "files") }}{% if results.truncated %} (stopped early; narrow the search){% endif %}{% endif %}</small></h1></div>
9 +
<form class="card wide" method="get" action="{{ repo.url }}/search">
10 +
  <div class="row">
11 +
    <div><input type="search" name="q" value="{{ q }}" autofocus placeholder="text, or a regular expression"></div>
12 +
    <div><input type="text" name="path" value="{{ path }}" placeholder="under path (optional)"></div>
13 +
    <div><button type="submit">SEARCH</button></div>
14 +
  </div>
15 +
  <input type="hidden" name="ref" value="{{ r }}">
16 +
  <div class="choice"><label><input type="checkbox" name="re" value="1"{% if regex %} checked{% endif %}> regular expression</label><label><input type="checkbox" name="case" value="1"{% if case %} checked{% endif %}> match case</label></div>
17 +
</form>
18 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
19 +
{% if !q.is_empty() && results.hits.is_empty() && error.is_none() %}<p class="empty">Nothing matches.</p>{% endif %}
20 +
<div class="hits">
21 +
{% for h in results.hits %}
22 +
  {% if h.line == 0 %}<p class="hit"><a href="{{ repo.url }}/file/{{ r }}/{{ h.path }}"><strong>{{ h.path }}</strong></a> <span class="small mute">path matches</span></p>
23 +
  {% else %}<p class="hit"><a href="{{ repo.url }}/file/{{ r }}/{{ h.path }}#L{{ h.line }}"><span class="mute">{{ h.path }}:{{ h.line }}</span></a> <code>{{ h.text }}</code></p>{% endif %}
24 +
{% endfor %}
25 +
</div>
26 +
{% endblock %}
added templates/release_new.html +22 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}New release · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "releases") %}{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>New release <small>notes and assets on a signed tag</small></h1></div>
8 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 +
{% if tags.is_empty() %}<p class="empty">This repository has no tags. <code>oxy tag create v1.0 -m "First release"</code> and push, then come back.</p>{% else %}
10 +
<form class="card wide" method="get" action="{{ repo.url }}/releases/new">
11 +
  <div class="row"><div><label for="tag">Tag</label><select id="tag" name="tag" onchange="this.form.submit()">{% for t in tags %}<option value="{{ t }}"{% if *t == tag %} selected{% endif %}>{{ t }}</option>{% endfor %}</select></div></div>
12 +
</form>
13 +
<form class="card wide" method="post" action="{{ repo.url }}/releases/new">
14 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
15 +
  <input type="hidden" name="tag" value="{{ tag }}">
16 +
  <label for="title">Title</label><input type="text" id="title" name="title" value="{{ tag }}">
17 +
  <label for="notes">Notes (drafted from the changes since the previous release)</label><textarea id="notes" name="notes" class="md" rows="14">{{ notes }}</textarea>
18 +
  <div class="choice"><label><input type="checkbox" name="prerelease" value="1"> pre-release</label></div>
19 +
  <button type="submit">PUBLISH</button>
20 +
</form>
21 +
{% endif %}
22 +
{% endblock %}
added templates/releases.html +41 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Releases · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "releases") %}{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>Releases <small>{{ releases.len() }}</small></h1></div>
8 +
{% if !one && repo.can(crate::auth::Access::Write) %}
9 +
<div class="bar"><a class="go small" href="{{ repo.url }}/releases/new">draft a release</a>{% if !unreleased.is_empty() %}<span class="small mute">tags without a release: {% for t in unreleased %}<a href="{{ repo.url }}/releases/new?tag={{ t }}">{{ t }}</a>{% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</div>
10 +
{% endif %}
11 +
{% if releases.is_empty() %}<p class="empty">No releases yet. Tag a state with <code>oxy tag create v1.0 -m "…"</code>, push it, then draft a release here.</p>{% endif %}
12 +
{% for v in releases %}
13 +
<section class="panel release">
14 +
  <h2><a href="{{ repo.url }}/releases/tag/{{ v.r.tag }}">{{ v.r.title }}</a>
15 +
    {% if v.latest %}<span class="tag head">latest</span>{% endif %}{% if v.r.prerelease %}<span class="tag warn">pre-release</span>{% endif %}
16 +
    <span class="small mute">{{ v.r.tag }}{% if let Some(s) = v.signer %} · <span class="sig sig-valid">✔</span> signed by {{ s }}{% else %} · <span class="sig sig-unsigned">○</span> unsigned tag{% endif %} · {{ v.r.author }} · <time title="{{ v.r.created_ms|when_i }}">{{ v.r.created_ms|ago_i }}</time></span></h2>
17 +
  {% if !v.r.notes.is_empty() %}<article class="doc">{{ v.notes_html|safe }}</article>{% endif %}
18 +
  <h3>Assets</h3>
19 +
  <ul class="plain">
20 +
    {% for a in v.assets %}<li><a href="{{ repo.url }}/asset/{{ a.id }}/{{ a.name }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.hash }}">{{ a.hash|short }}</code> · {{ a.downloads }} downloads</span>
21 +
      {% if one && repo.can(crate::auth::Access::Write) %}<form class="tiny" method="post" action="{{ repo.url }}/releases/edit/{{ v.r.id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="asset" value="{{ a.id }}"><button name="action" value="delete_asset">delete</button></form>{% endif %}</li>{% endfor %}
22 +
    <li><a href="{{ repo.url }}/archive?ref={{ v.r.tag }}&format=tar.zst">Source ({{ v.r.tag }}.tar.zst)</a></li>
23 +
    <li><a href="{{ repo.url }}/archive?ref={{ v.r.tag }}&format=zip">Source ({{ v.r.tag }}.zip)</a></li>
24 +
  </ul>
25 +
  {% if one && repo.can(crate::auth::Access::Write) %}
26 +
  <form class="card" method="post" action="{{ repo.url }}/releases/upload/{{ v.r.id }}" enctype="multipart/form-data">
27 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
28 +
    <label for="asset">Attach files</label><input type="file" id="asset" name="asset" multiple required>
29 +
    <button type="submit">UPLOAD</button>
30 +
  </form>
31 +
  <form class="card wide" method="post" action="{{ repo.url }}/releases/edit/{{ v.r.id }}">
32 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
33 +
    <label for="title">Title</label><input type="text" id="title" name="title" value="{{ v.r.title }}">
34 +
    <label for="notes">Notes</label><textarea id="notes" name="notes" class="md" rows="8">{{ v.r.notes }}</textarea>
35 +
    <div class="choice"><label><input type="checkbox" name="prerelease" value="1"{% if v.r.prerelease %} checked{% endif %}> pre-release</label></div>
36 +
    <div class="choice"><button type="submit" name="action" value="save">SAVE</button> <button type="submit" name="action" value="delete" class="danger-btn" onclick="return confirm('Delete this release? The tag stays.')">DELETE RELEASE</button></div>
37 +
  </form>
38 +
  {% endif %}
39 +
</section>
40 +
{% endfor %}
41 +
{% endblock %}
modified Cargo.lock +83 −0
…
91 unchanged lines
92 92
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
93 93
94 94
[[package]]
95 +
name = "arbitrary"
96 +
version = "1.4.2"
97 +
source = "registry+https://github.com/rust-lang/crates.io-index"
98 +
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
99 +
dependencies = [
100 +
 "derive_arbitrary",
101 +
]
102 +
103 +
[[package]]
95 104
name = "arc-swap"
96 105
version = "1.9.2"
97 106
source = "registry+https://github.com/rust-lang/crates.io-index"
…
732 unchanged lines
830 839
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
831 840
832 841
[[package]]
842 +
name = "derive_arbitrary"
843 +
version = "1.4.2"
844 +
source = "registry+https://github.com/rust-lang/crates.io-index"
845 +
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
846 +
dependencies = [
847 +
 "proc-macro2",
848 +
 "quote",
849 +
 "syn 2.0.119",
850 +
]
851 +
852 +
[[package]]
833 853
name = "digest"
834 854
version = "0.10.7"
835 855
source = "registry+https://github.com/rust-lang/crates.io-index"
…
176 unchanged lines
1012 1032
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
1013 1033
1014 1034
[[package]]
1035 +
name = "filetime"
1036 +
version = "0.2.29"
1037 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1038 +
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
1039 +
dependencies = [
1040 +
 "cfg-if",
1041 +
 "libc",
1042 +
]
1043 +
1044 +
[[package]]
1015 1045
name = "find-msvc-tools"
1016 1046
version = "0.1.14"
1017 1047
source = "registry+https://github.com/rust-lang/crates.io-index"
…
1046 unchanged lines
2064 2094
 "sqlx",
2065 2095
 "subtle",
2066 2096
 "syntect",
2097 +
 "tar",
2067 2098
 "tempfile",
2068 2099
 "thiserror 2.0.21",
2069 2100
 "time",
…
4 unchanged lines
2074 2105
 "tower-http",
2075 2106
 "tracing",
2076 2107
 "tracing-subscriber",
2108 +
 "zip",
2109 +
 "zstd",
2077 2110
]
2078 2111
2079 2112
[[package]]
…
1245 unchanged lines
3325 3358
]
3326 3359
3327 3360
[[package]]
3361 +
name = "tar"
3362 +
version = "0.4.46"
3363 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3364 +
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
3365 +
dependencies = [
3366 +
 "filetime",
3367 +
 "libc",
3368 +
 "xattr",
3369 +
]
3370 +
3371 +
[[package]]
3328 3372
name = "tempfile"
3329 3373
version = "3.27.0"
3330 3374
source = "registry+https://github.com/rust-lang/crates.io-index"
…
781 unchanged lines
4112 4156
]
4113 4157
4114 4158
[[package]]
4159 +
name = "xattr"
4160 +
version = "1.6.1"
4161 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4162 +
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
4163 +
dependencies = [
4164 +
 "libc",
4165 +
 "rustix",
4166 +
]
4167 +
4168 +
[[package]]
4115 4169
name = "yaml-rust"
4116 4170
version = "0.4.5"
4117 4171
source = "registry+https://github.com/rust-lang/crates.io-index"
…
116 unchanged lines
4234 4288
]
4235 4289
4236 4290
[[package]]
4291 +
name = "zip"
4292 +
version = "2.4.2"
4293 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4294 +
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
4295 +
dependencies = [
4296 +
 "arbitrary",
4297 +
 "crc32fast",
4298 +
 "crossbeam-utils",
4299 +
 "displaydoc",
4300 +
 "flate2",
4301 +
 "indexmap",
4302 +
 "memchr",
4303 +
 "thiserror 2.0.21",
4304 +
 "zopfli",
4305 +
]
4306 +
4307 +
[[package]]
4237 4308
name = "zlib-rs"
4238 4309
version = "0.6.8"
4239 4310
source = "registry+https://github.com/rust-lang/crates.io-index"
…
6 unchanged lines
4246 4317
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4247 4318
4248 4319
[[package]]
4320 +
name = "zopfli"
4321 +
version = "0.8.3"
4322 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4323 +
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
4324 +
dependencies = [
4325 +
 "bumpalo",
4326 +
 "crc32fast",
4327 +
 "log",
4328 +
 "simd-adler32",
4329 +
]
4330 +
4331 +
[[package]]
4249 4332
name = "zstd"
4250 4333
version = "0.13.3"
4251 4334
source = "registry+https://github.com/rust-lang/crates.io-index"
…
23 unchanged lines
modified Cargo.toml +4 −1
…
17 unchanged lines
18 18
thiserror = "2"
19 19
serde = { version = "1", features = ["derive"] }
20 20
serde_json = "1"
21 -
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "signal", "fs"] }
21 +
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "signal", "fs", "process", "io-util"] }
22 22
axum = { version = "0.8", features = ["macros", "form", "query", "multipart"] }
23 23
axum-server = { version = "0.8", default-features = false, features = ["tls-rustls-no-provider"] }
24 24
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
…
20 unchanged lines
45 45
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
46 46
subtle = "2"
47 47
blake3 = "1"
48 +
tar = "0.4"
49 +
zstd = "0.13"
50 +
zip = { version = "2", default-features = false, features = ["deflate"] }
48 51
lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "smtp-transport", "builder"] }
49 52
regex = "1"
50 53
tempfile = "3"
…
7 unchanged lines
modified README.md +6 −4
…
9 unchanged lines
10 10
## Status
11 11
12 12
Milestones 0.1 (browse), 0.2 (accounts and access), 0.3 (proposals and
13 -
review), 0.4 (issues and notifications) and 0.5 (API, webhooks, checks) are
14 -
done: pages show
13 +
review), 0.4 (issues and notifications), 0.5 (API, webhooks, checks) and
14 +
0.6 (web edits, releases, mirror, code search) are done: pages show
15 15
trees, files, blame, history with the dependency graph, changes with diffs,
16 16
comparisons, bookmarks, tags, conflicts, the trust policy and the push log;
17 17
accounts sign in with a password and optional TOTP, create repositories and
…
6 unchanged lines
24 24
inbox with watching, subscriptions, mentions and optional email; search
25 25
over issues, proposals, repositories and accounts; a JSON API for all of
26 26
it with scoped tokens, webhooks with signed deliveries and retries, and
27 -
checks on state hashes that gate landing. Web edits, releases and the Git
28 -
mirror follow in 0.6.
27 +
checks on state hashes that gate landing; editing and conflict resolution
28 +
in the browser, signed by the hub as your delegate; releases with assets
29 +
and source archives; a read-only Git mirror for Git-only tools; and code
30 +
search. The runner follows in 0.7.
29 31
30 32
## Running
31 33
…
32 unchanged lines
modified crates/hub-api/src/lib.rs +48 −0
…
502 unchanged lines
503 503
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
504 504
pub struct NewComment {
505 505
    pub body: String,
506 +
    /// For a proposal: anchor the comment to this file's line (1-based) of
507 +
    /// the projected state; the hub records the line's atom.
508 +
    #[serde(default)]
509 +
    pub path: Option<String>,
510 +
    #[serde(default)]
511 +
    pub line: Option<usize>,
506 512
}
507 513
508 514
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
…
42 unchanged lines
551 557
    pub check: Option<Check>,
552 558
}
553 559
560 +
// --------------------------------------------------------------- releases
561 +
562 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
563 +
pub struct Release {
564 +
    pub tag: String,
565 +
    pub title: String,
566 +
    pub notes: String,
567 +
    pub prerelease: bool,
568 +
    pub latest: bool,
569 +
    pub author: String,
570 +
    pub heads: Vec<String>,
571 +
    pub signer: Option<String>,
572 +
    pub assets: Vec<ReleaseAsset>,
573 +
    pub archives: Vec<String>,
574 +
    pub created_ms: i64,
575 +
    pub url: String,
576 +
}
577 +
578 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
579 +
pub struct ReleaseAsset {
580 +
    pub name: String,
581 +
    pub size: i64,
582 +
    /// BLAKE3 of the content, hex.
583 +
    pub hash: String,
584 +
    pub content_type: String,
585 +
    pub downloads: i64,
586 +
    pub url: String,
587 +
}
588 +
589 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
590 +
pub struct NewRelease {
591 +
    pub tag: String,
592 +
    pub title: String,
593 +
    #[serde(default)]
594 +
    pub notes: Option<String>,
595 +
    #[serde(default)]
596 +
    pub prerelease: bool,
597 +
}
598 +
554 599
/// Every type of the API, for schema generation.
555 600
pub fn schemas() -> Vec<(&'static str, schemars::Schema)> {
556 601
    vec![
…
40 unchanged lines
597 642
        ("IssueStateChange", schemars::schema_for!(IssueStateChange)),
598 643
        ("WebhookEnvelope", schemars::schema_for!(WebhookEnvelope)),
599 644
        ("ProposalEvent", schemars::schema_for!(ProposalEvent)),
645 +
        ("Release", schemars::schema_for!(Release)),
646 +
        ("ReleaseAsset", schemars::schema_for!(ReleaseAsset)),
647 +
        ("NewRelease", schemars::schema_for!(NewRelease)),
600 648
    ]
601 649
}
modified crates/hub-api/ts/index.d.ts +33 −0
…
316 unchanged lines
317 317
318 318
export type NewComment = {
319 319
  body: string;
320 +
  line?: number | null;
321 +
  path?: string | null;
320 322
};
321 323
322 324
export type NewProposal = {
…
29 unchanged lines
352 354
  number: number;
353 355
};
354 356
357 +
export type ReleaseAsset = {
358 +
  content_type: string;
359 +
  downloads: number;
360 +
  hash: string;
361 +
  name: string;
362 +
  size: number;
363 +
  url: string;
364 +
};
365 +
366 +
export type Release = {
367 +
  archives: string[];
368 +
  assets: ReleaseAsset[];
369 +
  author: string;
370 +
  created_ms: number;
371 +
  heads: string[];
372 +
  latest: boolean;
373 +
  notes: string;
374 +
  prerelease: boolean;
375 +
  signer?: string | null;
376 +
  tag: string;
377 +
  title: string;
378 +
  url: string;
379 +
};
380 +
381 +
export type NewRelease = {
382 +
  notes?: string | null;
383 +
  prerelease?: boolean;
384 +
  tag: string;
385 +
  title: string;
386 +
};
387 +
modified crates/hub/Cargo.toml +3 −0
…
45 unchanged lines
46 46
qrcode.workspace = true
47 47
subtle.workspace = true
48 48
blake3.workspace = true
49 +
tar.workspace = true
50 +
zstd.workspace = true
51 +
zip.workspace = true
49 52
lettre.workspace = true
50 53
regex.workspace = true
51 54
reqwest.workspace = true
…
7 unchanged lines
modified crates/hub/src/api/mod.rs +86 −1
…
47 unchanged lines
48 48
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
49 49
        .route("/repos/{owner}/{name}/checks", post(post_check))
50 50
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
51 +
        .route("/repos/{owner}/{name}/releases", get(releases).post(create_release))
52 +
        .route("/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset))
53 +
}
54 +
55 +
async fn releases(
56 +
    State(app): State<Shared>,
57 +
    headers: HeaderMap,
58 +
    Path((owner, name)): Path<(String, String)>,
59 +
) -> Result<Json<Vec<Release>>, ApiError> {
60 +
    let c = caller(&app, &headers).await?;
61 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
62 +
    Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?))
63 +
}
64 +
65 +
async fn create_release(
66 +
    State(app): State<Shared>,
67 +
    headers: HeaderMap,
68 +
    Path((owner, name)): Path<(String, String)>,
69 +
    Json(body): Json<NewRelease>,
70 +
) -> Result<(StatusCode, Json<Release>), ApiError> {
71 +
    let c = caller(&app, &headers).await?;
72 +
    let me = c.require("repo:write")?.clone();
73 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
74 +
    crate::web::releases::create_release(&app, &repo, &row, &me, &body.tag, &body.title, body.notes, body.prerelease)
75 +
        .await
76 +
        .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
77 +
    let r = crate::web::releases::api_views(&app, &repo, &row)
78 +
        .await?
79 +
        .into_iter()
80 +
        .find(|r| r.tag == body.tag)
81 +
        .ok_or_else(|| not_found("no such release"))?;
82 +
    Ok((StatusCode::CREATED, Json(r)))
83 +
}
84 +
85 +
#[derive(Deserialize)]
86 +
struct AssetQuery {
87 +
    name: String,
51 88
}
52 89
90 +
/// `POST …/releases/{tag}/assets?name=file.tar.gz` with the file as the body.
91 +
async fn upload_asset(
92 +
    State(app): State<Shared>,
93 +
    headers: HeaderMap,
94 +
    Path((owner, name, tag)): Path<(String, String, String)>,
95 +
    Query(q): Query<AssetQuery>,
96 +
    body: axum::body::Bytes,
97 +
) -> Result<(StatusCode, Json<Release>), ApiError> {
98 +
    let c = caller(&app, &headers).await?;
99 +
    c.require("repo:write")?;
100 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
101 +
    let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?;
102 +
    let ct = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream").to_string();
103 +
    crate::web::releases::store_asset(&app, r.id, &q.name, &ct, &body).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
104 +
    let r = crate::web::releases::api_views(&app, &repo, &row)
105 +
        .await?
106 +
        .into_iter()
107 +
        .find(|r| r.tag == tag)
108 +
        .ok_or_else(|| not_found("no such release"))?;
109 +
    Ok((StatusCode::CREATED, Json(r)))
110 +
}
111 +
53 112
pub struct ApiError(StatusCode, String);
54 113
55 114
impl IntoResponse for ApiError {
…
563 unchanged lines
619 678
    if body.body.trim().is_empty() {
620 679
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
621 680
    }
622 -
    let row_c = db::proposals::add_comment(&app.db, p.id, Some(me.id), &me.username, body.body.trim(), "", None).await?;
681 +
    let (repo, _) = (RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?, ());
682 +
    let anchor = match (body.path.as_deref().map(|x| x.trim_matches('/')), body.line) {
683 +
        (Some(path), Some(line)) if !path.is_empty() => {
684 +
            let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await?;
685 +
            let (heads, file) = (loaded.heads.clone(), path.to_string());
686 +
            let atom = app
687 +
                .repos
688 +
                .read(&repo, move |r, cache| {
689 +
                    let projected = cache.load(r, &heads)?;
690 +
                    Ok(crate::repos::proposals::atom_at(&projected, &file, line).map(|(h, i)| (h.to_hex(), i)))
691 +
                })
692 +
                .await?
693 +
                .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, format!("{path}:{line} is not a line of the projected state")))?;
694 +
            Some((path.to_string(), atom))
695 +
        }
696 +
        _ => None,
697 +
    };
698 +
    let row_c = db::proposals::add_comment(
699 +
        &app.db,
700 +
        p.id,
701 +
        Some(me.id),
702 +
        &me.username,
703 +
        body.body.trim(),
704 +
        anchor.as_ref().map(|(path, _)| path.as_str()).unwrap_or(""),
705 +
        anchor.as_ref().map(|(_, (h, i))| (h.as_str(), *i as i64)),
706 +
    )
707 +
    .await?;
623 708
    crate::notify::deliver(
624 709
        &app,
625 710
        crate::notify::Event {
…
428 unchanged lines
modified crates/hub/src/db/mod.rs +1 −0
…
5 unchanged lines
6 6
pub mod issues;
7 7
pub mod notify;
8 8
pub mod proposals;
9 +
pub mod releases;
9 10
pub mod repos;
10 11
11 12
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
…
23 unchanged lines
modified crates/hub/src/db/repos.rs +14 −0
…
13 unchanged lines
14 14
    pub visibility: String,
15 15
    pub created_ms: i64,
16 16
    pub updated_ms: i64,
17 +
    #[sqlx(default)]
18 +
    pub git_mirror: bool,
19 +
    #[sqlx(default)]
20 +
    pub git_mirror_state: String,
17 21
}
18 22
19 23
impl RepoRow {
…
116 unchanged lines
136 140
        .fetch_all(pool)
137 141
        .await
138 142
}
143 +
144 +
pub async fn set_git_mirror(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> {
145 +
    sqlx::query("UPDATE repositories SET git_mirror = ? WHERE id = ?").bind(on).bind(id).execute(pool).await?;
146 +
    Ok(())
147 +
}
148 +
149 +
pub async fn set_git_mirror_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
150 +
    sqlx::query("UPDATE repositories SET git_mirror_state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?;
151 +
    Ok(())
152 +
}
modified crates/hub/src/lib.rs +1 −0
…
9 unchanged lines
10 10
pub mod events;
11 11
pub mod hooks;
12 12
pub mod keys;
13 +
pub mod mirror;
13 14
pub mod notify;
14 15
pub mod protocol;
15 16
pub mod render;
…
81 unchanged lines
modified crates/hub/src/protocol.rs +3 −0
…
177 unchanged lines
178 178
            let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
179 179
            if let Some(app) = app {
180 180
                crate::web::issues::close_from_messages(&app, &row, &messages).await;
181 +
                if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) {
182 +
                    crate::mirror::refresh(&app, &row).await;
183 +
                }
181 184
                let accepted: Vec<serde_json::Value> = results
182 185
                    .iter()
183 186
                    .filter(|r| r.ok)
…
36 unchanged lines
modified crates/hub/src/repos/mod.rs +4 −1
…
4 unchanged lines
5 5
//! the repository's lock (§16.2): pushes and other writes take the write
6 6
//! half, page reads the read half.
7 7
8 +
pub mod archive;
8 9
pub mod cache;
9 10
pub mod edits;
10 11
pub mod names;
11 12
pub mod proposals;
13 +
pub mod search;
12 14
pub mod view;
13 15
14 16
pub use cache::{Loaded, StateCache};
…
10 unchanged lines
25 27
    root: PathBuf,
26 28
    locks: Mutex<HashMap<String, Arc<RwLock<()>>>>,
27 29
    cache: Arc<StateCache>,
30 +
    pub searches: search::SearchCache,
28 31
}
29 32
30 33
#[derive(Debug, thiserror::Error)]
…
12 unchanged lines
43 46
44 47
impl Repos {
45 48
    pub fn new(root: PathBuf) -> Repos {
46 -
        Repos { root, locks: Mutex::new(HashMap::new()), cache: Arc::new(StateCache::new(64)) }
49 +
        Repos { root, locks: Mutex::new(HashMap::new()), cache: Arc::new(StateCache::new(64)), searches: search::SearchCache::default() }
47 50
    }
48 51
49 52
    pub fn cache(&self) -> &Arc<StateCache> {
…
111 unchanged lines
modified crates/hub/src/web/edit.rs +1 −0
…
224 unchanged lines
225 225
    crate::hooks::emit(&app, &row, "push", &me.account.username, serde_json::json!({ "updates": [{ "reference": done.bookmark, "old_state": done.old_state, "new_state": done.new_state, "heads": done.heads.len(), "changes": 1 }], "via": "web" })).await;
226 226
    if done.bookmark == row.default_bookmark {
227 227
        super::issues::close_from_messages(&app, &row, &[(done.change.to_hex(), message.clone())]).await;
228 +
        crate::mirror::refresh(&app, &row).await;
228 229
    }
229 230
    accounts::audit(
230 231
        &app.db,
…
23 unchanged lines
modified crates/hub/src/web/mod.rs +75 −0
…
12 unchanged lines
13 13
mod orgs;
14 14
pub mod proposals;
15 15
mod refs;
16 +
pub mod releases;
16 17
mod repo;
17 18
mod repo_settings;
18 19
mod session;
…
90 unchanged lines
109 110
        .route("/{owner}/{name}/edit/{r}", post(edit::save))
110 111
        .route("/{owner}/{name}/edit/{r}/{*path}", get(edit::edit_form))
111 112
        .route("/{owner}/{name}/newfile/{r}", get(edit::new_form))
113 +
        .route("/{owner}/{name}/releases", get(releases::list))
114 +
        .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create))
115 +
        .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show))
116 +
        .route("/{owner}/{name}/releases/edit/{id}", post(releases::edit))
117 +
        .route(
118 +
            "/{owner}/{name}/releases/upload/{id}",
119 +
            post(releases::upload).layer(DefaultBodyLimit::max(app.config.limits.asset_limit.0 as usize + (1 << 20))),
120 +
        )
121 +
        .route("/{owner}/{name}/asset/{id}/{file}", get(releases::download))
122 +
        .route("/{owner}/{name}/archive", get(releases::source_archive))
123 +
        .route("/{owner}/{name}/search", get(repo::code_search))
124 +
        .route("/{owner}/{name}/info/{*rest}", any(git_info))
125 +
        .route("/{owner}/{name}/HEAD", get(git_head))
126 +
        .route("/{owner}/{name}/objects/{*rest}", get(git_objects))
127 +
        .route("/{owner}/{name}/git-upload-pack", post(git_upload_pack))
128 +
        .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack))
129 +
        .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror))
112 130
        .route("/{owner}/{name}/issues", get(issues::list))
113 131
        .route("/{owner}/{name}/issues/new", get(issues::new_form).post(issues::create))
114 132
        .route("/{owner}/{name}/issue/{number}", get(issues::show))
…
15 unchanged lines
130 148
        .with_state(app)
131 149
}
132 150
151 +
// Git's smart HTTP paths under `/{owner}/{name}.git/`, forwarded to the
152 +
// mirror (hub spec §14).
153 +
async fn git_info(
154 +
    st: axum::extract::State<Shared>,
155 +
    axum::extract::Path((o, n, rest)): axum::extract::Path<(String, String, String)>,
156 +
    m: axum::http::Method,
157 +
    q: axum::extract::RawQuery,
158 +
    h: axum::http::HeaderMap,
159 +
    b: axum::body::Bytes,
160 +
) -> Response {
161 +
    crate::mirror::serve(st, axum::extract::Path((o, n, format!("info/{rest}"))), m, q, h, b).await
162 +
}
163 +
164 +
async fn git_head(
165 +
    st: axum::extract::State<Shared>,
166 +
    axum::extract::Path((o, n)): axum::extract::Path<(String, String)>,
167 +
    m: axum::http::Method,
168 +
    q: axum::extract::RawQuery,
169 +
    h: axum::http::HeaderMap,
170 +
    b: axum::body::Bytes,
171 +
) -> Response {
172 +
    crate::mirror::serve(st, axum::extract::Path((o, n, "HEAD".into())), m, q, h, b).await
173 +
}
174 +
175 +
async fn git_objects(
176 +
    st: axum::extract::State<Shared>,
177 +
    axum::extract::Path((o, n, rest)): axum::extract::Path<(String, String, String)>,
178 +
    m: axum::http::Method,
179 +
    q: axum::extract::RawQuery,
180 +
    h: axum::http::HeaderMap,
181 +
    b: axum::body::Bytes,
182 +
) -> Response {
183 +
    crate::mirror::serve(st, axum::extract::Path((o, n, format!("objects/{rest}"))), m, q, h, b).await
184 +
}
185 +
186 +
async fn git_upload_pack(
187 +
    st: axum::extract::State<Shared>,
188 +
    axum::extract::Path((o, n)): axum::extract::Path<(String, String)>,
189 +
    m: axum::http::Method,
190 +
    q: axum::extract::RawQuery,
191 +
    h: axum::http::HeaderMap,
192 +
    b: axum::body::Bytes,
193 +
) -> Response {
194 +
    crate::mirror::serve(st, axum::extract::Path((o, n, "git-upload-pack".into())), m, q, h, b).await
195 +
}
196 +
197 +
async fn git_receive_pack(
198 +
    st: axum::extract::State<Shared>,
199 +
    axum::extract::Path((o, n)): axum::extract::Path<(String, String)>,
200 +
    m: axum::http::Method,
201 +
    q: axum::extract::RawQuery,
202 +
    h: axum::http::HeaderMap,
203 +
    b: axum::body::Bytes,
204 +
) -> Response {
205 +
    crate::mirror::serve(st, axum::extract::Path((o, n, "git-receive-pack".into())), m, q, h, b).await
206 +
}
207 +
133 208
/// Cross-site form posts are refused (hub spec §15.2): a browser that
134 209
/// sends `Origin` or `Sec-Fetch-Site` must name this site. Protocol and
135 210
/// API clients send neither.
…
279 unchanged lines
modified crates/hub/src/web/repo.rs +86 −1
…
17 unchanged lines
18 18
    o: view::Overview,
19 19
    readme_html: Option<String>,
20 20
    watching: bool,
21 +
    latest_release: Option<crate::db::releases::Release>,
21 22
}
22 23
23 24
pub async fn overview(
…
12 unchanged lines
36 37
        Some(a) => crate::db::notify::watching(&app.db, a.id, row.id).await.unwrap_or(false),
37 38
        None => false,
38 39
    };
39 -
    Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching }))
40 +
    let latest_release = crate::db::releases::latest(&app.db, row.id).await.ok().flatten();
41 +
    Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release }))
40 42
}
41 43
42 44
impl view::Overview {
…
194 unchanged lines
237 239
        }
238 240
    }
239 241
}
242 +
243 +
#[derive(Deserialize)]
244 +
pub struct CodeQuery {
245 +
    #[serde(default)]
246 +
    q: String,
247 +
    #[serde(default, rename = "ref")]
248 +
    r: Option<String>,
249 +
    #[serde(default)]
250 +
    path: String,
251 +
    #[serde(default)]
252 +
    re: Option<String>,
253 +
    #[serde(default)]
254 +
    case: Option<String>,
255 +
}
256 +
257 +
#[derive(Template)]
258 +
#[template(path = "code_search.html")]
259 +
pub struct CodeSearchPage {
260 +
    shell: Shell,
261 +
    repo: RepoShell,
262 +
    q: String,
263 +
    r: String,
264 +
    path: String,
265 +
    regex: bool,
266 +
    case: bool,
267 +
    results: crate::repos::search::Results,
268 +
    error: Option<String>,
269 +
}
270 +
271 +
/// `GET /{owner}/{name}/search?q=…&ref=…&re=1&case=1&path=…` (hub spec §11).
272 +
pub async fn code_search(
273 +
    State(app): State<Shared>,
274 +
    viewer: Viewer,
275 +
    Path((owner, name)): Path<(String, String)>,
276 +
    Query(q): Query<CodeQuery>,
277 +
) -> Result<Page<CodeSearchPage>, Error> {
278 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/search"), &viewer);
279 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
280 +
    let r = q.r.clone().filter(|x| !x.is_empty()).unwrap_or_else(|| row.default_bookmark.clone());
281 +
    let (regex, case) = (q.re.is_some(), q.case.is_some());
282 +
    let mut results = crate::repos::search::Results::default();
283 +
    let mut error = None;
284 +
    if !q.q.trim().is_empty() {
285 +
        let (text, spec, prefix) = (q.q.clone(), r.clone(), q.path.trim_matches('/').to_string());
286 +
        let app2 = app.clone();
287 +
        let out = app
288 +
            .repos
289 +
            .read(&repo, move |repo, cache| {
290 +
                let target = view::resolve(repo, cache, &spec)?;
291 +
                let loaded = cache.load(repo, &target.heads)?;
292 +
                let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex();
293 +
                let key = format!("{state}\0{text}\0{regex}\0{case}\0{prefix}");
294 +
                if let Some(hit) = app2.repos.searches.get(&key) {
295 +
                    return Ok(Ok(hit));
296 +
                }
297 +
                let res = crate::repos::search::run(
298 +
                    &loaded,
299 +
                    &crate::repos::search::Query { text: &text, regex, case_sensitive: case, path_prefix: &prefix },
300 +
                );
301 +
                if let Ok(r) = &res {
302 +
                    app2.repos.searches.put(key, r.clone());
303 +
                }
304 +
                Ok(res)
305 +
            })
306 +
            .await
307 +
            .map_err(|e| Error::from_repo(shell.clone(), e))?;
308 +
        match out {
309 +
            Ok(r) => results = r,
310 +
            Err(e) => error = Some(e),
311 +
        }
312 +
    }
313 +
    Ok(Page(CodeSearchPage {
314 +
        shell,
315 +
        repo: RepoShell::new(&app, &row, "code").with_role(role),
316 +
        q: q.q,
317 +
        r,
318 +
        path: q.path,
319 +
        regex,
320 +
        case,
321 +
        results,
322 +
        error,
323 +
    }))
324 +
}
modified crates/hub/src/web/repo_settings.rs +30 −0
…
422 unchanged lines
423 423
    db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?;
424 424
    settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await
425 425
}
426 +
427 +
#[derive(Deserialize)]
428 +
pub struct MirrorForm {
429 +
    #[serde(rename = "_csrf")]
430 +
    csrf_token: String,
431 +
    #[serde(default)]
432 +
    enabled: Option<String>,
433 +
}
434 +
435 +
pub async fn mirror(
436 +
    State(app): State<Shared>,
437 +
    viewer: Viewer,
438 +
    Path((owner, name)): Path<(String, String)>,
439 +
    Form(f): Form<MirrorForm>,
440 +
) -> Result<Page<RepoSettingsPage>, Error> {
441 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
442 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
443 +
    csrf(&viewer, &f.csrf_token, &shell)?;
444 +
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
445 +
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
446 +
    if row.git_mirror {
447 +
        crate::mirror::refresh(&app, &row).await;
448 +
    }
449 +
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
450 +
        .await
451 +
        .ok();
452 +
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
453 +
    let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." };
454 +
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
455 +
}
modified docs/plan.md +14 −1
…
62 unchanged lines
63 63
- [x] Tests: `tests/api.rs` with a local webhook receiver that fails the first attempt.
64 64
- [ ] Organisation webhooks page (the API and delivery path handle them; no settings page yet).
65 65
66 +
## 0.6 Web edits, releases, mirror, code search (2026-09-27)
67 +
68 +
- [x] Oxygen: `Repo::build_change`, `host_ref_update`, `apply_hosted`; delegates of a policy rule's identities may sign reference and tag attestations, on push and on fetch (spec §14.5, Draft 0.8g).
69 +
- [x] Web edits: edit, create, rename and delete files with CodeMirror 6 (bundled from npm in `web/editor`, served from `assets/vendor`); EasyMDE for Markdown fields; commit to the bookmark or propose a new one. Authored by the user's linked identity, signed by the hub key as its delegate, applied through the push checks; refused with instructions until the user delegates.
70 +
- [x] Browser conflict resolution: conflicted text files open with their markers; a save without markers is a resolution change depending on every side.
71 +
- [x] Hub landing under a policy through the delegate key.
72 +
- [x] Releases on tags: generated notes from the changes since the previous release, pre-releases, the latest marker, assets stored by BLAKE3 with download counts, source archives (`.tar.zst`, `.zip`) of any state built once and cached; API to list, create and upload; webhook `release.published`.
73 +
- [x] Read-only Git mirror of the default bookmark: exported with `oxy git export` after every update, served by `git http-backend` behind the hub's access checks (Basic auth with a token for private repositories), pushes refused.
74 +
- [x] Code search per repository: substring or regex, match case, path prefix, a 2 s budget and 300-hit cap, cached per state.
75 +
- [x] API: anchored proposal comments (`path`, `line`).
76 +
- [x] Tests: `tests/edits.rs`, `tests/releases.rs` (real `git clone` and `git push` against the mirror).
77 +
- [ ] Organisation webhooks page; import from GitHub and GitLab; trigram code index (1.x).
78 +
66 79
## Later
67 80
68 -
- 0.6 web edits, releases, mirror · 0.7 runner · 1.0 hardening.
81 +
- 0.7 runner · 1.0 hardening.
69 82
70 83
## Notes
71 84
…
8 unchanged lines
modified templates/overview.html +2 −0
…
12 unchanged lines
13 13
      <span class="ref px"><a href="{{ repo.url }}/bookmarks">◆ {{ repo.default_bookmark }}</a></span>
14 14
      <a class="go small" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ o.changes|plural("change", "changes") }}</a>
15 15
      {% if repo.can(crate::auth::Access::Write) %}<a class="go small" href="{{ repo.url }}/newfile/{{ repo.default_bookmark }}">new file</a>{% endif %}
16 +
      <form class="find inline-find" method="get" action="{{ repo.url }}/search"><input type="search" name="q" placeholder="Search code…"></form>
16 17
      {% if let Some(c) = o.latest %}<span class="small mute">latest <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> · {{ c.when_ms|ago }}</span>{% endif %}
17 18
    </div>
18 19
    {% include "tree_table.html" %}
…
9 unchanged lines
28 29
    <div class="clone"><code>oxy clone {{ repo.clone_url }}</code> <button class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div>
29 30
    <h3>Bookmarks</h3>
30 31
    <ul class="plain">{% for b in o.bookmarks %}<li><a href="{{ repo.url }}/tree/{{ b.name }}">{{ b.name }}</a>{% if b.heads.len() > 1 %} <span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}{% if b.conflicted %} <span class="tag bad">conflicts</span>{% endif %}</li>{% endfor %}</ul>
32 +
    {% if let Some(rel) = latest_release %}<h3>Latest release</h3><p><a href="{{ repo.url }}/releases/tag/{{ rel.tag }}">{{ rel.title }}</a> <span class="small mute">{{ rel.created_ms|ago_i }}</span></p>{% endif %}
31 33
    <h3>Tags</h3>
32 34
    {% if o.tags.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain">{% for t in o.tags %}<li><a href="{{ repo.url }}/tree/{{ t.name }}">{{ t.name }}</a>{% if t.signer.is_some() %} <span class="sig sig-valid" title="signed">✔</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
33 35
    <h3>Trust</h3>
…
9 unchanged lines
modified templates/repo_settings.html +7 −0
…
29 unchanged lines
30 30
  </div>
31 31
  <button type="submit">SAVE</button>
32 32
</form>
33 +
<form class="card wide" method="post" action="{{ repo.url }}/settings/mirror">
34 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
35 +
  <h2>Git mirror</h2>
36 +
  <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if row.git_mirror %} checked{% endif %}> keep a read-only Git mirror of <strong>{{ row.default_bookmark }}</strong></label></div>
37 +
  <p class="small mute">For tools that only speak Git (CI runners, IDEs): <code>git clone {{ shell.base_url }}{{ repo.url }}.git</code>. Private repositories take a personal access token as the password. Pushing to the mirror is refused.{% if row.git_mirror && !row.git_mirror_state.is_empty() %} Last exported state <code>{{ row.git_mirror_state|short }}</code>.{% endif %}</p>
38 +
  <button type="submit">SAVE</button>
39 +
</form>
33 40
<form class="card wide" method="post" action="{{ repo.url }}/settings/checks">
34 41
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
35 42
  <h2>Required checks</h2>
…
51 unchanged lines
modified templates/repo_tabs.html +1 −0
…
6 unchanged lines
7 7
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals</a>
8 8
    <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues</a>
9 9
    <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on"{% endif %}>Bookmarks</a>
10 +
    <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on"{% endif %}>Releases</a>
10 11
    <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on"{% endif %}>Tags</a>
11 12
    <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} class="on"{% endif %}>Conflicts</a>
12 13
    <a href="{{ repo.url }}/trust"{% if tab == "trust" %} class="on"{% endif %}>Trust</a>
…
49 unchanged lines
modified templates/style.css +5 −0
…
295 unchanged lines
296 296
.editor-toolbar button.mde-btn { color: var(--ink); border: 0; background: transparent; font-weight: 600; font-size: 12px; text-transform: none; letter-spacing: 0; padding: 2px 6px; width: auto; }
297 297
.editor-toolbar button.mde-btn:hover, .editor-toolbar button.active { background: var(--panel); color: var(--acc); }
298 298
.editor-preview, .editor-preview-side { background: var(--panel); color: var(--ink); }
299 +
300 +
/* code search */
301 +
.inline-find { flex: 0 1 220px; }
302 +
.hits .hit { margin: 0; padding: 4px 8px; border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; overflow-x: auto; white-space: nowrap; }
303 +
.hits .hit code { background: none; }