An assistant drafts a new repository's starting files; your Flow runs over the API; 0.9.13 (closes #347, refs andymccall/oxygenhub-android#26) 3d086bc3d69b
POST /api/v1/repos/O/N/starter {description}: the owner's assistant (the site's or the organisation's) drafts a starting point for an empty repository in the background; the answer's files are checked (relative paths, text, at most 40 files and 256 KiB) and committed to the default bookmark as the caller, the hub signing as their delegate, the change naming the assistant and what was asked; never over existing files. GET …/starter follows it; GET /user/starter?owner=X lists the assistants. GET /user/flow/runs: the newest runs of the caller's repositories with their jobs, ?before= pages back.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 17810fb5e151
- When
- · 2026-10-28 17:32 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
3d086bc3d69bce9d301a29144eda53851f72a366fe3693b7fc7a418bd1f95137- Depends on
- 1edac4b68c6d 1f78655a0466 25ab8c73514a 2a0c09c09716 42fa95540468 4dfa4599b3f7 5714347a6233 5c6e92fd8d71 5d818ec7d5fc 8306eca4e4a3 851a26a2a6d1 89e89836cae7 93be9ce4653e b5668108487f ced6d3afc5cf d23464c0ac60 ecac6e8d9b41
- Depended on by
- 8f1d76f4169a fac6853c4e46
- In bookmarks
- main
- In tags
- v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 18 atoms, 2 tree operations, 11 files
Browse the tree at this change
added crates/hub/src/starter.rs +254 −0
| 1 | + | //! A new repository's starting files, drafted by an assistant (#347): the |
|
| 2 | + | //! person creating it describes what they want, an assistant set up for the |
|
| 3 | + | //! owner (the site's, or the organisation's) answers with files, and the |
|
| 4 | + | //! hub commits them to the empty repository's default bookmark, authored by |
|
| 5 | + | //! that person and signed by the hub as their delegate, as a web edit is. |
|
| 6 | + | //! The change says which assistant drafted it and what was asked. It runs |
|
| 7 | + | //! in the background, since a model may take longer than a proxied request |
|
| 8 | + | //! may last; its state is kept here until the hub restarts. |
|
| 9 | + | ||
| 10 | + | use crate::assistants::{self, Assistant, Available, Scope}; |
|
| 11 | + | use crate::db::{self, Pool}; |
|
| 12 | + | use crate::repos::RepoName; |
|
| 13 | + | use crate::Shared; |
|
| 14 | + | use oxy_core::repo::FileEdit; |
|
| 15 | + | use std::collections::HashMap; |
|
| 16 | + | use std::path::PathBuf; |
|
| 17 | + | use std::sync::{LazyLock, Mutex}; |
|
| 18 | + | ||
| 19 | + | /// The most a description may say. |
|
| 20 | + | pub const MAX_DESCRIPTION: usize = 4000; |
|
| 21 | + | /// The most files a starter may have, the largest one and all of them. |
|
| 22 | + | pub const MAX_FILES: usize = 40; |
|
| 23 | + | pub const MAX_FILE: usize = 64 * 1024; |
|
| 24 | + | pub const MAX_TOTAL: usize = 256 * 1024; |
|
| 25 | + | ||
| 26 | + | /// The assistants a new repository under `owner` may ask: the site's, and |
|
| 27 | + | /// the organisation's when `owner` is one, as its repositories would see. |
|
| 28 | + | pub async fn assistants_for(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Available>> { |
|
| 29 | + | let scope = match db::accounts::org_by_name(pool, owner).await? { |
|
| 30 | + | Some(org) => Scope::Org(org.id), |
|
| 31 | + | None => Scope::Site, |
|
| 32 | + | }; |
|
| 33 | + | Ok(assistants::available(pool, scope, owner).await?.into_iter().filter(Available::on).collect()) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | /// Where a repository's starter stands. |
|
| 37 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 38 | + | pub enum State { |
|
| 39 | + | Running { assistant: String }, |
|
| 40 | + | Done { assistant: String, change: String, files: Vec<String> }, |
|
| 41 | + | Failed { assistant: String, error: String }, |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | static STATES: LazyLock<Mutex<HashMap<PathBuf, State>>> = LazyLock::new(|| Mutex::new(HashMap::new())); |
|
| 45 | + | ||
| 46 | + | fn key(app: &Shared, name: &RepoName) -> PathBuf { |
|
| 47 | + | app.repos.dir(name) |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// The starter of `name` on this hub, if one was asked for since it started. |
|
| 51 | + | pub fn state(app: &Shared, name: &RepoName) -> Option<State> { |
|
| 52 | + | STATES.lock().unwrap_or_else(|p| p.into_inner()).get(&key(app, name)).cloned() |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | fn set(app: &Shared, name: &RepoName, s: State) { |
|
| 56 | + | STATES.lock().unwrap_or_else(|p| p.into_inner()).insert(key(app, name), s); |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | /// What the assistant is told: what to write and the one shape to answer in. |
|
| 60 | + | pub fn system_prompt(a: &Assistant, site: &str, owner_prompts: &str) -> String { |
|
| 61 | + | let mut s = format!( |
|
| 62 | + | "You are {name}, an AI assistant writing the first files of a new repository on {site}, an OxygenHub site, \ |
|
| 63 | + | for the person who just created it; they read, change and own everything you write. \ |
|
| 64 | + | Make a small, working starting point for what they describe: a README.md saying what it is and how to build, \ |
|
| 65 | + | run and test it; the build files the language's own tools expect; a minimal source layout; and a first test \ |
|
| 66 | + | where the ecosystem has a convention. No secrets, no placeholder credentials, no binary files, nothing \ |
|
| 67 | + | generated (lock files, build output). At most {MAX_FILES} files.\n\n\ |
|
| 68 | + | Answer with files only, each written as\n\n\ |
|
| 69 | + | <<<FILE path/from/the/repository/root>>>\n\ |
|
| 70 | + | the file's whole content\n\ |
|
| 71 | + | <<<END>>>\n\n\ |
|
| 72 | + | with nothing before the first file and nothing after the last.", |
|
| 73 | + | name = a.name |
|
| 74 | + | ); |
|
| 75 | + | if !owner_prompts.trim().is_empty() { |
|
| 76 | + | s.push_str("\n\n"); |
|
| 77 | + | s.push_str(owner_prompts.trim()); |
|
| 78 | + | } |
|
| 79 | + | s |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// The files an answer holds, checked: relative paths without `..`, text, |
|
| 83 | + | /// within the limits. `Err` says what was wrong, for the person who asked. |
|
| 84 | + | pub fn parse_files(answer: &str) -> Result<Vec<(String, String)>, String> { |
|
| 85 | + | let mut files: Vec<(String, String)> = vec![]; |
|
| 86 | + | let mut current: Option<(String, Vec<&str>)> = None; |
|
| 87 | + | for line in answer.lines() { |
|
| 88 | + | let t = line.trim_end(); |
|
| 89 | + | if let Some((path, body)) = current.as_mut() { |
|
| 90 | + | if t.trim() == "<<<END>>>" { |
|
| 91 | + | let path = std::mem::take(path); |
|
| 92 | + | let content = body.join("\n") + "\n"; |
|
| 93 | + | current = None; |
|
| 94 | + | files.push((path, content)); |
|
| 95 | + | } else { |
|
| 96 | + | body.push(line); |
|
| 97 | + | } |
|
| 98 | + | continue; |
|
| 99 | + | } |
|
| 100 | + | if let Some(rest) = t.trim().strip_prefix("<<<FILE ") { |
|
| 101 | + | let path = rest.strip_suffix(">>>").ok_or_else(|| format!("a file line is not closed with >>>: {t:?}"))?.trim(); |
|
| 102 | + | current = Some((path.to_string(), vec![])); |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | if let Some((path, _)) = current { |
|
| 106 | + | return Err(format!("the answer stopped inside {path:?} (no <<<END>>>)")); |
|
| 107 | + | } |
|
| 108 | + | if files.is_empty() { |
|
| 109 | + | return Err("the assistant answered without any files".into()); |
|
| 110 | + | } |
|
| 111 | + | if files.len() > MAX_FILES { |
|
| 112 | + | return Err(format!("the assistant wrote {} files; a starter has at most {MAX_FILES}", files.len())); |
|
| 113 | + | } |
|
| 114 | + | let mut total = 0; |
|
| 115 | + | let mut seen = std::collections::BTreeSet::new(); |
|
| 116 | + | for (path, content) in &files { |
|
| 117 | + | check_path(path)?; |
|
| 118 | + | if !seen.insert(path.clone()) { |
|
| 119 | + | return Err(format!("the assistant wrote {path} twice")); |
|
| 120 | + | } |
|
| 121 | + | if content.len() > MAX_FILE { |
|
| 122 | + | return Err(format!("{path} is {} bytes; a starter's files are at most {MAX_FILE}", content.len())); |
|
| 123 | + | } |
|
| 124 | + | if content.contains('\0') { |
|
| 125 | + | return Err(format!("{path} is not text")); |
|
| 126 | + | } |
|
| 127 | + | total += content.len(); |
|
| 128 | + | } |
|
| 129 | + | if total > MAX_TOTAL { |
|
| 130 | + | return Err(format!("the starter is {total} bytes; at most {MAX_TOTAL}")); |
|
| 131 | + | } |
|
| 132 | + | Ok(files) |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | fn check_path(path: &str) -> Result<(), String> { |
|
| 136 | + | let bad = |why: &str| Err(format!("the path {path:?} {why}")); |
|
| 137 | + | if path.is_empty() || path.len() > 200 { |
|
| 138 | + | return bad("is empty or longer than 200 characters"); |
|
| 139 | + | } |
|
| 140 | + | if path.starts_with('/') || path.contains('\\') || path.contains(':') { |
|
| 141 | + | return bad("is not relative to the repository's root"); |
|
| 142 | + | } |
|
| 143 | + | for part in path.split('/') { |
|
| 144 | + | if part.is_empty() || part == "." || part == ".." { |
|
| 145 | + | return bad("has an empty, . or .. part"); |
|
| 146 | + | } |
|
| 147 | + | if part.eq_ignore_ascii_case(".oxy") { |
|
| 148 | + | return bad("is inside the repository's own metadata"); |
|
| 149 | + | } |
|
| 150 | + | if part.chars().any(char::is_control) { |
|
| 151 | + | return bad("has a control character"); |
|
| 152 | + | } |
|
| 153 | + | } |
|
| 154 | + | Ok(()) |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | /// Whether `name`'s default bookmark holds nothing yet. |
|
| 158 | + | pub async fn is_empty(app: &Shared, name: &RepoName, bookmark: &str) -> Result<bool, String> { |
|
| 159 | + | let b = bookmark.to_string(); |
|
| 160 | + | app.repos |
|
| 161 | + | .read(name, move |r, _| { |
|
| 162 | + | let view = r.view()?; |
|
| 163 | + | Ok(match view.bookmarks.get(&b) { |
|
| 164 | + | None => true, |
|
| 165 | + | Some(st) => r.store().get_state(st)?.heads.is_empty(), |
|
| 166 | + | }) |
|
| 167 | + | }) |
|
| 168 | + | .await |
|
| 169 | + | .map_err(|e| e.to_string()) |
|
| 170 | + | } |
|
| 171 | + | ||
| 172 | + | /// Ask `a` for `row`'s starting files in the background, for `by`, and |
|
| 173 | + | /// commit them; [`state`] follows it. |
|
| 174 | + | pub fn start(app: Shared, row: db::repos::RepoRow, by: db::accounts::Account, a: Assistant, description: String) { |
|
| 175 | + | let Some(name) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 176 | + | set(&app, &name, State::Running { assistant: a.name.clone() }); |
|
| 177 | + | tokio::spawn(async move { |
|
| 178 | + | let outcome = run(&app, &row, &by, &a, &description).await; |
|
| 179 | + | let s = match outcome { |
|
| 180 | + | Ok((change, files)) => State::Done { assistant: a.name.clone(), change, files }, |
|
| 181 | + | Err(error) => { |
|
| 182 | + | tracing::warn!("starter for {}/{}: {error}", row.owner, row.name); |
|
| 183 | + | State::Failed { assistant: a.name.clone(), error } |
|
| 184 | + | } |
|
| 185 | + | }; |
|
| 186 | + | set(&app, &name, s); |
|
| 187 | + | }); |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | async fn run( |
|
| 191 | + | app: &Shared, |
|
| 192 | + | row: &db::repos::RepoRow, |
|
| 193 | + | by: &db::accounts::Account, |
|
| 194 | + | a: &Assistant, |
|
| 195 | + | description: &str, |
|
| 196 | + | ) -> Result<(String, Vec<String>), String> { |
|
| 197 | + | let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 198 | + | let signer = crate::web::edit::signer_for(app, by.id).await?; |
|
| 199 | + | let scope = match db::accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| e.to_string())? { |
|
| 200 | + | Some(org) => Scope::Org(org.id), |
|
| 201 | + | None => Scope::Site, |
|
| 202 | + | }; |
|
| 203 | + | let mut prompts = assistants::inherited_prompts(&app.db, scope, &row.owner).await.map_err(|e| e.to_string())?; |
|
| 204 | + | let own = assistants::prompt(&app.db, scope).await.map_err(|e| e.to_string())?; |
|
| 205 | + | if !own.is_empty() && scope != Scope::Site { |
|
| 206 | + | prompts.push((format!("the {} organisation", row.owner), own)); |
|
| 207 | + | } else if !own.is_empty() { |
|
| 208 | + | prompts.push(("the site".to_string(), own)); |
|
| 209 | + | } |
|
| 210 | + | let prompts: String = |
|
| 211 | + | prompts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::<Vec<_>>().join("\n"); |
|
| 212 | + | let site = app.config.base_url(); |
|
| 213 | + | let user = format!("The repository is {}/{}.\n\nWhat they want:\n\n{}", row.owner, row.name, description.trim()); |
|
| 214 | + | let answer = assistants::ask(app, a, &system_prompt(a, site, &prompts), &user, assistants::BACKGROUND_TIMEOUT).await?; |
|
| 215 | + | let files = parse_files(&answer)?; |
|
| 216 | + | if !is_empty(app, &name, &row.default_bookmark).await? { |
|
| 217 | + | return Err("the repository has files now, so the starter was not added".into()); |
|
| 218 | + | } |
|
| 219 | + | let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect(); |
|
| 220 | + | let edits: Vec<FileEdit> = files.into_iter().map(|(path, content)| FileEdit { path, content: Some(content.into_bytes()) }).collect(); |
|
| 221 | + | let short: String = description.trim().chars().take(400).collect(); |
|
| 222 | + | let message = format!("Starter files drafted by {} ({})\n\nAsked for by {}: {short}", a.name, a.model, by.username); |
|
| 223 | + | let b = row.default_bookmark.clone(); |
|
| 224 | + | let done = crate::repos::edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await.map_err(|e| e.to_string())?; |
|
| 225 | + | db::accounts::audit(&app.db, Some(by), "repo.starter", &name.to_string(), &format!("{} files drafted by {}", paths.len(), a.name)) |
|
| 226 | + | .await |
|
| 227 | + | .ok(); |
|
| 228 | + | Ok((done.change.to_hex(), paths)) |
|
| 229 | + | } |
|
| 230 | + | ||
| 231 | + | #[cfg(test)] |
|
| 232 | + | mod tests { |
|
| 233 | + | use super::*; |
|
| 234 | + | ||
| 235 | + | #[test] |
|
| 236 | + | fn an_answer_is_read_as_files_and_checked() { |
|
| 237 | + | let answer = "<<<FILE README.md>>>\n# Hello\n<<<END>>>\n<<<FILE src/main.rs>>>\nfn main() {}\n<<<END>>>\n"; |
|
| 238 | + | let files = parse_files(answer).unwrap(); |
|
| 239 | + | assert_eq!( |
|
| 240 | + | files, |
|
| 241 | + | vec![("README.md".to_string(), "# Hello\n".to_string()), ("src/main.rs".to_string(), "fn main() {}\n".to_string())] |
|
| 242 | + | ); |
|
| 243 | + | assert!(parse_files("Sure! Here you go.").unwrap_err().contains("without any files")); |
|
| 244 | + | assert!(parse_files("<<<FILE a>>>\nx").unwrap_err().contains("no <<<END>>>")); |
|
| 245 | + | for bad in ["/etc/passwd", "../up", "a//b", "C:\\x", ".oxy/config"] { |
|
| 246 | + | assert!(parse_files(&format!("<<<FILE {bad}>>>\nx\n<<<END>>>")).is_err(), "{bad}"); |
|
| 247 | + | } |
|
| 248 | + | assert!(parse_files("<<<FILE a>>>\n1\n<<<END>>>\n<<<FILE a>>>\n2\n<<<END>>>").unwrap_err().contains("twice")); |
|
| 249 | + | let many: String = (0..=MAX_FILES).map(|i| format!("<<<FILE f{i}>>>\nx\n<<<END>>>\n")).collect(); |
|
| 250 | + | assert!(parse_files(&many).unwrap_err().contains("at most")); |
|
| 251 | + | let big = format!("<<<FILE big>>>\n{}\n<<<END>>>", "x".repeat(MAX_FILE + 1)); |
|
| 252 | + | assert!(parse_files(&big).unwrap_err().contains("at most")); |
|
| 253 | + | } |
|
| 254 | + | } |
added crates/hub/tests/starter.rs +224 −0
| 1 | + | //! A new repository's starting files drafted by an assistant (#347): asked |
|
| 2 | + | //! for over the API, drafted in the background by the owner's assistant, |
|
| 3 | + | //! committed to the empty repository as the person who asked (the hub as |
|
| 4 | + | //! their delegate), never over files that are there, and refused plainly |
|
| 5 | + | //! when no assistant is set up or the person has not delegated to the hub. |
|
| 6 | + | ||
| 7 | + | use axum::body::Body; |
|
| 8 | + | use axum::http::{header, Request, StatusCode}; |
|
| 9 | + | use http_body_util::BodyExt; |
|
| 10 | + | use oxy_core::repo::{IdentityScope, Repo}; |
|
| 11 | + | use oxygenhub::config::Config; |
|
| 12 | + | use oxygenhub::db; |
|
| 13 | + | use oxygenhub::Shared; |
|
| 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 | + | struct Hub { |
|
| 28 | + | app: Shared, |
|
| 29 | + | _dir: tempfile::TempDir, |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | async fn hub() -> Hub { |
|
| 33 | + | hermetic(); |
|
| 34 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 35 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 36 | + | config.registration.setup_code = "test-setup".into(); |
|
| 37 | + | config.registration.mode = "open".into(); |
|
| 38 | + | // The stand-in model is on this machine. |
|
| 39 | + | config.outbound.allow = vec!["http://127.0.0.1".into()]; |
|
| 40 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 41 | + | let port = listener.local_addr().unwrap().port(); |
|
| 42 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 43 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 44 | + | let router = oxygenhub::router(app.clone()); |
|
| 45 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 46 | + | Hub { app, _dir: dir } |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | struct Res { |
|
| 50 | + | status: StatusCode, |
|
| 51 | + | body: String, |
|
| 52 | + | cookie: Option<String>, |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, token: Option<&str>, body: Option<(&str, String)>) -> Res { |
|
| 56 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 57 | + | if let Some(c) = cookie { |
|
| 58 | + | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 59 | + | } |
|
| 60 | + | if let Some(t) = token { |
|
| 61 | + | req = req.header(header::AUTHORIZATION, format!("Bearer {t}")); |
|
| 62 | + | } |
|
| 63 | + | let body = match body { |
|
| 64 | + | Some((ct, b)) => { |
|
| 65 | + | req = req.header(header::CONTENT_TYPE, ct); |
|
| 66 | + | Body::from(b) |
|
| 67 | + | } |
|
| 68 | + | None => Body::empty(), |
|
| 69 | + | }; |
|
| 70 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 71 | + | let status = res.status(); |
|
| 72 | + | let cookie = res |
|
| 73 | + | .headers() |
|
| 74 | + | .get(header::SET_COOKIE) |
|
| 75 | + | .and_then(|v| v.to_str().ok()) |
|
| 76 | + | .and_then(|v| v.split(';').next()) |
|
| 77 | + | .and_then(|kv| kv.split_once('=')) |
|
| 78 | + | .map(|(_, v)| v.to_string()); |
|
| 79 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 80 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
|
| 84 | + | send(app, "POST", path, cookie, None, Some(("application/x-www-form-urlencoded", form.to_string()))).await |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | async fn api(app: &Shared, method: &str, path: &str, token: Option<&str>, json: Option<serde_json::Value>) -> Res { |
|
| 88 | + | send(app, method, &format!("/api/v1{path}"), None, token, json.map(|j| ("application/json", j.to_string()))).await |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | async fn register(app: &Shared, user: &str) -> String { |
|
| 92 | + | let r = post( |
|
| 93 | + | app, |
|
| 94 | + | "/register", |
|
| 95 | + | None, |
|
| 96 | + | &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 97 | + | ) |
|
| 98 | + | .await; |
|
| 99 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 100 | + | r.cookie.unwrap() |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// `user`'s identity, delegating to the hub's key and linked to the |
|
| 104 | + | /// account, as web edits need; the client checkout that holds it. |
|
| 105 | + | async fn delegate(app: &Shared, user: &str, work: &Path) -> Repo { |
|
| 106 | + | let id = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap().id; |
|
| 107 | + | let mut client = Repo::init(work).unwrap(); |
|
| 108 | + | client.create_identity(user, &format!("{user}@example.com"), IdentityScope::Repo).unwrap(); |
|
| 109 | + | client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap(); |
|
| 110 | + | let key = client.config.author_key.unwrap(); |
|
| 111 | + | let ident = client.identity(&key).unwrap(); |
|
| 112 | + | db::accounts::link_identity(&app.db, id, &key.to_hex(), &ident.name, &ident.email, &ident.encode()).await.unwrap(); |
|
| 113 | + | client |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | /// A stand-in model: files for any request, but no files when asked to make-no-files. |
|
| 117 | + | async fn model() -> String { |
|
| 118 | + | let router = axum::Router::new().route( |
|
| 119 | + | "/v1/chat/completions", |
|
| 120 | + | axum::routing::post(|axum::Json(req): axum::Json<serde_json::Value>| async move { |
|
| 121 | + | let asked = req.to_string(); |
|
| 122 | + | let content = if asked.contains("make-no-files") { |
|
| 123 | + | "Sure! I can help with that.".to_string() |
|
| 124 | + | } else { |
|
| 125 | + | "<<<FILE README.md>>>\n# Pong\n\nA small pong game.\n<<<END>>>\n<<<FILE src/main.rs>>>\nfn main() {}\n<<<END>>>\n" |
|
| 126 | + | .to_string() |
|
| 127 | + | }; |
|
| 128 | + | axum::Json(serde_json::json!({ "choices": [{ "message": { "content": content } }] })) |
|
| 129 | + | }), |
|
| 130 | + | ); |
|
| 131 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 132 | + | let port = listener.local_addr().unwrap().port(); |
|
| 133 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 134 | + | format!("http://127.0.0.1:{port}/v1/chat/completions") |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | async fn admin_token(app: &Shared, user: &str) -> String { |
|
| 138 | + | let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap(); |
|
| 139 | + | let (secret, hash) = oxygenhub::auth::new_token(); |
|
| 140 | + | db::accounts::create_token(&app.db, me.id, "phone", &hash, "repo:read,repo:write,repo:admin", "", None).await.unwrap(); |
|
| 141 | + | secret |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | async fn wait_done(app: &Shared, repo: &str, token: &str) -> serde_json::Value { |
|
| 145 | + | for _ in 0..100 { |
|
| 146 | + | let r = api(app, "GET", &format!("/repos/{repo}/starter"), Some(token), None).await; |
|
| 147 | + | let v: serde_json::Value = serde_json::from_str(&r.body).unwrap(); |
|
| 148 | + | if v["status"] != "running" { |
|
| 149 | + | return v; |
|
| 150 | + | } |
|
| 151 | + | tokio::time::sleep(std::time::Duration::from_millis(100)).await; |
|
| 152 | + | } |
|
| 153 | + | panic!("the starter for {repo} is still running"); |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 157 | + | async fn an_assistant_drafts_an_empty_repositorys_starting_files() { |
|
| 158 | + | let h = hub().await; |
|
| 159 | + | let app = &h.app; |
|
| 160 | + | register(app, "andy").await; |
|
| 161 | + | let work = tempfile::tempdir().unwrap(); |
|
| 162 | + | delegate(app, "andy", work.path()).await; |
|
| 163 | + | let t = admin_token(app, "andy").await; |
|
| 164 | + | let r = api(app, "POST", "/repos", Some(&t), Some(serde_json::json!({ "owner": "andy", "name": "pong" }))).await; |
|
| 165 | + | assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); |
|
| 166 | + | ||
| 167 | + | // No assistant yet: none offered, and asking says where to set one up. |
|
| 168 | + | let r = api(app, "GET", "/user/starter?owner=andy", Some(&t), None).await; |
|
| 169 | + | assert_eq!(r.body, r#"{"assistants":[]}"#); |
|
| 170 | + | let ask = serde_json::json!({ "description": "A pong game in Rust" }); |
|
| 171 | + | let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask.clone())).await; |
|
| 172 | + | assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body); |
|
| 173 | + | assert!(r.body.contains("no assistant is set up"), "{}", r.body); |
|
| 174 | + | ||
| 175 | + | // The site's assistant. |
|
| 176 | + | let ep = model().await; |
|
| 177 | + | let body = serde_json::json!({ "name": "Helper", "endpoint": ep, "api": "chat-completions", "model": "m1" }); |
|
| 178 | + | let r = api(app, "POST", "/admin/assistants", Some(&t), Some(body)).await; |
|
| 179 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 180 | + | let r = api(app, "GET", "/user/starter?owner=andy", Some(&t), None).await; |
|
| 181 | + | let v: serde_json::Value = serde_json::from_str(&r.body).unwrap(); |
|
| 182 | + | assert_eq!((v["assistants"][0]["name"].as_str(), v["assistants"][0]["from"].as_str()), (Some("Helper"), Some("the site")), "{v}"); |
|
| 183 | + | ||
| 184 | + | // Asked: it works in the background, then the files are the repository's first change. |
|
| 185 | + | let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask.clone())).await; |
|
| 186 | + | assert_eq!(r.status, StatusCode::ACCEPTED, "{}", r.body); |
|
| 187 | + | let v = wait_done(app, "andy/pong", &t).await; |
|
| 188 | + | assert_eq!(v["status"], "done", "{v}"); |
|
| 189 | + | assert_eq!(v["files"], serde_json::json!(["README.md", "src/main.rs"])); |
|
| 190 | + | assert_eq!(v["assistant"], "Helper"); |
|
| 191 | + | let r = api(app, "GET", "/repos/andy/pong/file/main/README.md", Some(&t), None).await; |
|
| 192 | + | assert!(r.body.contains("A small pong game."), "{}", r.body); |
|
| 193 | + | let r = api(app, "GET", "/repos/andy/pong/history/main", Some(&t), None).await; |
|
| 194 | + | assert!(r.body.contains("Starter files drafted by Helper (m1)") && r.body.contains("A pong game in Rust"), "{}", r.body); |
|
| 195 | + | ||
| 196 | + | // Never over files that are there, and the request must say something. |
|
| 197 | + | let r = api(app, "POST", "/repos/andy/pong/starter", Some(&t), Some(ask)).await; |
|
| 198 | + | assert_eq!(r.status, StatusCode::CONFLICT, "{}", r.body); |
|
| 199 | + | api(app, "POST", "/repos", Some(&t), Some(serde_json::json!({ "owner": "andy", "name": "empty" }))).await; |
|
| 200 | + | let r = api(app, "POST", "/repos/andy/empty/starter", Some(&t), Some(serde_json::json!({ "description": " " }))).await; |
|
| 201 | + | assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body); |
|
| 202 | + | ||
| 203 | + | // An answer with no files fails, saying so, and the repository stays empty. |
|
| 204 | + | let r = |
|
| 205 | + | api(app, "POST", "/repos/andy/empty/starter", Some(&t), Some(serde_json::json!({ "description": "make-no-files please" }))).await; |
|
| 206 | + | assert_eq!(r.status, StatusCode::ACCEPTED, "{}", r.body); |
|
| 207 | + | let v = wait_done(app, "andy/empty", &t).await; |
|
| 208 | + | assert_eq!(v["status"], "failed", "{v}"); |
|
| 209 | + | assert!(v["error"].as_str().unwrap().contains("without any files"), "{v}"); |
|
| 210 | + | assert_eq!(api(app, "GET", "/repos/andy/empty/starter", None, None).await.status, StatusCode::OK, "readable as the repository is"); |
|
| 211 | + | ||
| 212 | + | // Someone who has not delegated to the hub is told how. |
|
| 213 | + | register(app, "bob").await; |
|
| 214 | + | let b = admin_token(app, "bob").await; |
|
| 215 | + | assert_eq!( |
|
| 216 | + | api(app, "GET", "/user/starter?owner=andy", Some(&b), None).await.body, |
|
| 217 | + | r#"{"assistants":[]}"#, |
|
| 218 | + | "not an owner bob creates under" |
|
| 219 | + | ); |
|
| 220 | + | api(app, "POST", "/repos", Some(&b), Some(serde_json::json!({ "owner": "bob", "name": "game" }))).await; |
|
| 221 | + | let r = api(app, "POST", "/repos/bob/game/starter", Some(&b), Some(serde_json::json!({ "description": "A game" }))).await; |
|
| 222 | + | assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body); |
|
| 223 | + | assert!(r.body.contains("delegated"), "{}", r.body); |
|
| 224 | + | } |
modified CHANGELOG.md +5 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## 0.9.13 (2026-10-28) |
|
| 6 | + | ||
| 7 | + | - **An assistant drafts a new repository's starting files (#347).** Creating a repository can come with a description of what it is for: `POST /api/v1/repos/OWNER/NAME/starter {"description"}` asks the owner's assistant (the site's, or the organisation's) for a small working starting point (a README, the build files, a source layout and a first test), checks what comes back (relative paths, text, at most 40 files and 256 KiB), and commits it to the empty repository's default bookmark as the person who asked, signed by the hub as their delegate, the change naming the assistant and what was asked. It runs in the background, since a model may take longer than a proxied request may last; `GET …/starter` says how it went, and `GET /api/v1/user/starter?owner=X` which assistants may be asked. Never over files that are there. The Android app's New repository uses it. |
|
| 8 | + | - **Your Flow runs over the API (oxygenhub-android#26).** `GET /api/v1/user/flow/runs` gives the newest runs of the repositories you belong to, each with its repository and jobs (`?before=MS` pages back), for the Android app's Flow monitor. |
|
| 9 | + | ||
| 5 | 10 | ## 0.9.12 (2026-10-28) |
|
| 6 | 11 | ||
| 7 | 12 | - **A repository's sky over the API (oxygenhub-android#23).** `GET /api/v1/repos/OWNER/NAME/sky` gives the backdrop a repository's pages draw (#196), its events of the last 30 days, in the shape of `/user/sky`, for the Android app's repository screen; read access, as the repository's pages. |
|
| … | 401 unchanged lines |
modified Cargo.lock +3 −3
| … | 1459 unchanged lines |
||
| 1460 | 1460 | ||
| 1461 | 1461 | [[package]] |
|
| 1462 | 1462 | name = "hub-api" |
|
| 1463 | - | version = "0.9.12" |
|
| 1463 | + | version = "0.9.13" |
|
| 1464 | 1464 | dependencies = [ |
|
| 1465 | 1465 | "schemars", |
|
| 1466 | 1466 | "serde", |
|
| … | 797 unchanged lines |
||
| 2264 | 2264 | ||
| 2265 | 2265 | [[package]] |
|
| 2266 | 2266 | name = "oh" |
|
| 2267 | - | version = "0.9.12" |
|
| 2267 | + | version = "0.9.13" |
|
| 2268 | 2268 | dependencies = [ |
|
| 2269 | 2269 | "anyhow", |
|
| 2270 | 2270 | "axum", |
|
| … | 86 unchanged lines |
||
| 2357 | 2357 | ||
| 2358 | 2358 | [[package]] |
|
| 2359 | 2359 | name = "oxygenhub" |
|
| 2360 | - | version = "0.9.12" |
|
| 2360 | + | version = "0.9.13" |
|
| 2361 | 2361 | dependencies = [ |
|
| 2362 | 2362 | "ammonia", |
|
| 2363 | 2363 | "anyhow", |
|
| … | 2513 unchanged lines |
modified Cargo.toml +1 −1
| … | 6 unchanged lines |
||
| 7 | 7 | resolver = "2" |
|
| 8 | 8 | ||
| 9 | 9 | [workspace.package] |
|
| 10 | - | version = "0.9.12" |
|
| 10 | + | version = "0.9.13" |
|
| 11 | 11 | edition = "2021" |
|
| 12 | 12 | license = "MIT OR Apache-2.0" |
|
| 13 | 13 | authors = ["Andy McCall <[email protected]>"] |
|
| … | 60 unchanged lines |
modified crates/hub-api/src/lib.rs +59 −0
| … | 745 unchanged lines |
||
| 746 | 746 | pub events: Vec<[i64; 4]>, |
|
| 747 | 747 | } |
|
| 748 | 748 | ||
| 749 | + | /// `GET /user/flow/runs` (oxygenhub-android#26): a run of one of the |
|
| 750 | + | /// caller's repositories, with its jobs, for a monitor across them all. |
|
| 751 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 752 | + | pub struct UserFlowRun { |
|
| 753 | + | /// `owner/name`. |
|
| 754 | + | pub repository: String, |
|
| 755 | + | #[serde(flatten)] |
|
| 756 | + | pub run: FlowRun, |
|
| 757 | + | } |
|
| 758 | + | ||
| 759 | + | /// `GET /user/flow/runs` query: `before` (created before, ms) pages back. |
|
| 760 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] |
|
| 761 | + | pub struct UserFlowRunsQuery { |
|
| 762 | + | #[serde(default)] |
|
| 763 | + | pub limit: Option<i64>, |
|
| 764 | + | #[serde(default)] |
|
| 765 | + | pub before: Option<i64>, |
|
| 766 | + | } |
|
| 767 | + | ||
| 768 | + | /// `GET /user/starter?owner=X` (#347): the assistants a new repository |
|
| 769 | + | /// under `owner` may ask for its starting files; empty when none is set up |
|
| 770 | + | /// or the caller cannot create there. |
|
| 771 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 772 | + | pub struct StarterAssistants { |
|
| 773 | + | pub assistants: Vec<StarterAssistant>, |
|
| 774 | + | } |
|
| 775 | + | ||
| 776 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 777 | + | pub struct StarterAssistant { |
|
| 778 | + | pub name: String, |
|
| 779 | + | pub model: String, |
|
| 780 | + | /// `the site` or `the acme organisation`. |
|
| 781 | + | pub from: String, |
|
| 782 | + | } |
|
| 783 | + | ||
| 784 | + | /// `POST /repos/{owner}/{name}/starter`: what to make, and which assistant |
|
| 785 | + | /// (the first available when not given). |
|
| 786 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] |
|
| 787 | + | pub struct StarterRequest { |
|
| 788 | + | pub description: String, |
|
| 789 | + | #[serde(default)] |
|
| 790 | + | pub assistant: Option<String>, |
|
| 791 | + | } |
|
| 792 | + | ||
| 793 | + | /// Where a repository's starter stands: `running`, `done` (with the |
|
| 794 | + | /// change and its files), `failed` (with why), or `none`. |
|
| 795 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 796 | + | pub struct StarterStatus { |
|
| 797 | + | pub status: String, |
|
| 798 | + | #[serde(default, skip_serializing_if = "String::is_empty")] |
|
| 799 | + | pub assistant: String, |
|
| 800 | + | #[serde(default, skip_serializing_if = "String::is_empty")] |
|
| 801 | + | pub change: String, |
|
| 802 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 803 | + | pub files: Vec<String>, |
|
| 804 | + | #[serde(default, skip_serializing_if = "String::is_empty")] |
|
| 805 | + | pub error: String, |
|
| 806 | + | } |
|
| 807 | + | ||
| 749 | 808 | /// `GET /user/proposals` query. |
|
| 750 | 809 | #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] |
|
| 751 | 810 | pub struct UserProposalsQuery { |
|
| … | 926 unchanged lines |
modified crates/hub/src/api/mod.rs +144 −0
| … | 108 unchanged lines |
||
| 109 | 109 | .route("/user/orgs", get(user_orgs)) |
|
| 110 | 110 | .route("/user/proposals", get(user_proposals)) |
|
| 111 | 111 | .route("/user/sky", get(user_sky)) |
|
| 112 | + | .route("/user/starter", get(user_starter)) |
|
| 113 | + | .route("/user/flow/runs", get(user_flow_runs)) |
|
| 112 | 114 | .route("/user/runner-token", post(user_runner_token)) |
|
| 113 | 115 | .route("/user/token", axum::routing::delete(revoke_own_token)) |
|
| 114 | 116 | .route("/user/push", axum::routing::put(register_push).delete(forget_push)) |
|
| … | 92 unchanged lines |
||
| 207 | 209 | .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) |
|
| 208 | 210 | .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) |
|
| 209 | 211 | .route("/repos/{owner}/{name}/sky", get(repo_sky)) |
|
| 212 | + | .route("/repos/{owner}/{name}/starter", get(starter_status).post(start_starter)) |
|
| 210 | 213 | .route("/repos/{owner}/{name}/releases/{tag}", axum::routing::patch(edit_release)) |
|
| 211 | 214 | .route( |
|
| 212 | 215 | "/repos/{owner}/{name}/releases/{tag}/assets", |
|
| … | 599 unchanged lines |
||
| 812 | 815 | Ok(Json(hub_api::UserSky { now, repos: mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect(), events })) |
|
| 813 | 816 | } |
|
| 814 | 817 | ||
| 818 | + | /// `GET /user/flow/runs` (oxygenhub-android#26): the newest runs of the |
|
| 819 | + | /// repositories the caller belongs to and may read, each with its jobs, |
|
| 820 | + | /// newest first; `before` pages back by creation time. |
|
| 821 | + | async fn user_flow_runs( |
|
| 822 | + | State(app): State<Shared>, |
|
| 823 | + | headers: HeaderMap, |
|
| 824 | + | Query(q): Query<hub_api::UserFlowRunsQuery>, |
|
| 825 | + | ) -> Result<Json<Vec<hub_api::UserFlowRun>>, ApiError> { |
|
| 826 | + | let c = caller(&app, &headers).await?; |
|
| 827 | + | let me = c.require("repo:read")?.clone(); |
|
| 828 | + | let member = db::accounts::visible_repo_ids(&app.db, me.id).await?; |
|
| 829 | + | let mut mine = std::collections::HashMap::new(); |
|
| 830 | + | for r in db::repos::list(&app.db).await? { |
|
| 831 | + | if member.contains(&r.id) && auth::roles::allows(auth::roles::role_for(&app.db, Some(&me), &r).await?, Access::Read) { |
|
| 832 | + | mine.insert(r.id, r); |
|
| 833 | + | } |
|
| 834 | + | } |
|
| 835 | + | let ids: Vec<i64> = mine.keys().copied().collect(); |
|
| 836 | + | let limit = q.limit.unwrap_or(30).clamp(1, 100); |
|
| 837 | + | let runs = db::activity::recent_runs(&app.db, &ids, q.before.unwrap_or(i64::MAX), limit).await?; |
|
| 838 | + | let queue = if runs.iter().any(|r| r.live()) { db::flow::queue(&app.db).await? } else { vec![] }; |
|
| 839 | + | let mut out = Vec::with_capacity(runs.len()); |
|
| 840 | + | for run in &runs { |
|
| 841 | + | let Some(row) = mine.get(&run.repo_id) else { continue }; |
|
| 842 | + | let jobs = db::flow::jobs(&app.db, run.id).await?; |
|
| 843 | + | out.push(hub_api::UserFlowRun { |
|
| 844 | + | repository: format!("{}/{}", row.owner, row.name), |
|
| 845 | + | run: flow_run_of(&app, row, run, jobs, &queue), |
|
| 846 | + | }); |
|
| 847 | + | } |
|
| 848 | + | Ok(Json(out)) |
|
| 849 | + | } |
|
| 850 | + | ||
| 851 | + | #[derive(Deserialize)] |
|
| 852 | + | struct StarterQuery { |
|
| 853 | + | owner: String, |
|
| 854 | + | } |
|
| 855 | + | ||
| 856 | + | /// `GET /user/starter?owner=X` (#347): the assistants a new repository |
|
| 857 | + | /// under `owner` may ask for its starting files, when the caller may |
|
| 858 | + | /// create repositories there. |
|
| 859 | + | async fn user_starter( |
|
| 860 | + | State(app): State<Shared>, |
|
| 861 | + | headers: HeaderMap, |
|
| 862 | + | Query(q): Query<StarterQuery>, |
|
| 863 | + | ) -> Result<Json<hub_api::StarterAssistants>, ApiError> { |
|
| 864 | + | let c = caller(&app, &headers).await?; |
|
| 865 | + | let me = c.require("repo:read")?; |
|
| 866 | + | if !auth::roles::may_create_under(&app.db, me, q.owner.trim()).await? { |
|
| 867 | + | return Ok(Json(hub_api::StarterAssistants { assistants: vec![] })); |
|
| 868 | + | } |
|
| 869 | + | let found = crate::starter::assistants_for(&app.db, q.owner.trim()).await?; |
|
| 870 | + | Ok(Json(hub_api::StarterAssistants { |
|
| 871 | + | assistants: found |
|
| 872 | + | .into_iter() |
|
| 873 | + | .map(|a| hub_api::StarterAssistant { |
|
| 874 | + | name: a.assistant.name, |
|
| 875 | + | model: a.assistant.model, |
|
| 876 | + | from: if a.from.is_empty() { "the site".into() } else { a.from }, |
|
| 877 | + | }) |
|
| 878 | + | .collect(), |
|
| 879 | + | })) |
|
| 880 | + | } |
|
| 881 | + | ||
| 882 | + | fn starter_status_of(s: Option<crate::starter::State>) -> hub_api::StarterStatus { |
|
| 883 | + | use crate::starter::State; |
|
| 884 | + | let mut out = hub_api::StarterStatus { |
|
| 885 | + | status: "none".into(), |
|
| 886 | + | assistant: String::new(), |
|
| 887 | + | change: String::new(), |
|
| 888 | + | files: vec![], |
|
| 889 | + | error: String::new(), |
|
| 890 | + | }; |
|
| 891 | + | match s { |
|
| 892 | + | None => {} |
|
| 893 | + | Some(State::Running { assistant }) => (out.status, out.assistant) = ("running".into(), assistant), |
|
| 894 | + | Some(State::Done { assistant, change, files }) => { |
|
| 895 | + | (out.status, out.assistant, out.change, out.files) = ("done".into(), assistant, change, files) |
|
| 896 | + | } |
|
| 897 | + | Some(State::Failed { assistant, error }) => (out.status, out.assistant, out.error) = ("failed".into(), assistant, error), |
|
| 898 | + | } |
|
| 899 | + | out |
|
| 900 | + | } |
|
| 901 | + | ||
| 902 | + | /// `GET /repos/{owner}/{name}/starter` (#347): where its starter stands. |
|
| 903 | + | async fn starter_status( |
|
| 904 | + | State(app): State<Shared>, |
|
| 905 | + | headers: HeaderMap, |
|
| 906 | + | Path((owner, name)): Path<(String, String)>, |
|
| 907 | + | ) -> Result<Json<hub_api::StarterStatus>, ApiError> { |
|
| 908 | + | let c = caller(&app, &headers).await?; |
|
| 909 | + | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 910 | + | Ok(Json(starter_status_of(crate::starter::state(&app, &repo)))) |
|
| 911 | + | } |
|
| 912 | + | ||
| 913 | + | /// `POST /repos/{owner}/{name}/starter` (#347): ask an assistant for an |
|
| 914 | + | /// empty repository's starting files; `202` while it works, then |
|
| 915 | + | /// `GET …/starter` says how it went. |
|
| 916 | + | async fn start_starter( |
|
| 917 | + | State(app): State<Shared>, |
|
| 918 | + | headers: HeaderMap, |
|
| 919 | + | Path((owner, name)): Path<(String, String)>, |
|
| 920 | + | Json(body): Json<hub_api::StarterRequest>, |
|
| 921 | + | ) -> Result<(StatusCode, Json<hub_api::StarterStatus>), ApiError> { |
|
| 922 | + | let c = caller(&app, &headers).await?; |
|
| 923 | + | let me = c.require("repo:admin")?.clone(); |
|
| 924 | + | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; |
|
| 925 | + | let description = body.description.trim(); |
|
| 926 | + | if description.is_empty() { |
|
| 927 | + | return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "say what the repository is for (description)".into())); |
|
| 928 | + | } |
|
| 929 | + | if description.chars().count() > crate::starter::MAX_DESCRIPTION { |
|
| 930 | + | return Err(ApiError( |
|
| 931 | + | StatusCode::UNPROCESSABLE_ENTITY, |
|
| 932 | + | format!("a description is at most {} characters", crate::starter::MAX_DESCRIPTION), |
|
| 933 | + | )); |
|
| 934 | + | } |
|
| 935 | + | if matches!(crate::starter::state(&app, &repo), Some(crate::starter::State::Running { .. })) { |
|
| 936 | + | return Err(ApiError(StatusCode::CONFLICT, "a starter is being drafted for it already".into())); |
|
| 937 | + | } |
|
| 938 | + | if !crate::starter::is_empty(&app, &repo, &row.default_bookmark).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))? { |
|
| 939 | + | return Err(ApiError(StatusCode::CONFLICT, "the repository has files already; a starter is only for an empty one".into())); |
|
| 940 | + | } |
|
| 941 | + | let available = crate::starter::assistants_for(&app.db, &row.owner).await?; |
|
| 942 | + | let chosen = match body.assistant.as_deref().map(str::trim).filter(|n| !n.is_empty()) { |
|
| 943 | + | Some(n) => available.into_iter().find(|a| a.assistant.name.eq_ignore_ascii_case(n)), |
|
| 944 | + | None => available.into_iter().next(), |
|
| 945 | + | }; |
|
| 946 | + | let Some(a) = chosen else { |
|
| 947 | + | return Err(ApiError( |
|
| 948 | + | StatusCode::UNPROCESSABLE_ENTITY, |
|
| 949 | + | "no assistant is set up for this owner (Admin › Assistants, or the organisation's settings)".into(), |
|
| 950 | + | )); |
|
| 951 | + | }; |
|
| 952 | + | if let Err(why) = crate::web::edit::signer_for(&app, me.id).await { |
|
| 953 | + | return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, why)); |
|
| 954 | + | } |
|
| 955 | + | crate::starter::start(app.clone(), row, me, a.assistant, description.to_string()); |
|
| 956 | + | Ok((StatusCode::ACCEPTED, Json(starter_status_of(crate::starter::state(&app, &repo))))) |
|
| 957 | + | } |
|
| 958 | + | ||
| 815 | 959 | async fn user_repos( |
|
| 816 | 960 | State(app): State<Shared>, |
|
| 817 | 961 | headers: HeaderMap, |
|
| … | 2499 unchanged lines |
modified crates/hub/src/db/activity.rs +15 −0
| … | 214 unchanged lines |
||
| 215 | 215 | .fetch_all(pool) |
|
| 216 | 216 | .await |
|
| 217 | 217 | } |
|
| 218 | + | ||
| 219 | + | /// The newest Flow runs of `repo_ids`, created before `before_ms`, at most |
|
| 220 | + | /// `limit` (oxygenhub-android#26: the app's Flow monitor). |
|
| 221 | + | pub async fn recent_runs(pool: &Pool, repo_ids: &[i64], before_ms: i64, limit: i64) -> sqlx::Result<Vec<super::flow::Run>> { |
|
| 222 | + | sqlx::query_as(&each( |
|
| 223 | + | "WITH seen(id) AS (SELECT value FROM json_each($1)) \ |
|
| 224 | + | SELECT * FROM flow_runs WHERE repo_id IN (SELECT id FROM seen) AND created_ms < $2 \ |
|
| 225 | + | ORDER BY created_ms DESC, id DESC LIMIT $3", |
|
| 226 | + | )) |
|
| 227 | + | .bind(ids_json(repo_ids)) |
|
| 228 | + | .bind(before_ms) |
|
| 229 | + | .bind(limit) |
|
| 230 | + | .fetch_all(pool) |
|
| 231 | + | .await |
|
| 232 | + | } |
modified crates/hub/src/lib.rs +1 −0
| … | 50 unchanged lines |
||
| 51 | 51 | pub mod shutdown; |
|
| 52 | 52 | pub mod signin; |
|
| 53 | 53 | pub mod social; |
|
| 54 | + | pub mod starter; |
|
| 54 | 55 | pub mod storage; |
|
| 55 | 56 | pub mod swarm; |
|
| 56 | 57 | pub mod tags; |
|
| … | 373 unchanged lines |
modified crates/hub/tests/flow.rs +15 −0
| … | 609 unchanged lines |
||
| 610 | 610 | assert!(v["url"].as_str().unwrap().ends_with("/andy/demo/flow/2")); |
|
| 611 | 611 | let second = db::flow::run(&h.app.db, first[0].repo_id, 2).await.unwrap().unwrap(); |
|
| 612 | 612 | assert_eq!((second.trigger.as_str(), second.actor.as_str(), second.state.as_str()), ("manual", "andy", first[0].state.as_str())); |
|
| 613 | + | // Both, newest first, across andy's repositories with their jobs (oxygenhub-android#26). |
|
| 614 | + | let (st, body) = call(&h.app, "GET", "/api/v1/user/flow/runs", Some("site"), None).await; |
|
| 615 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 616 | + | let v: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 617 | + | assert_eq!(v.as_array().unwrap().len(), 2, "{v}"); |
|
| 618 | + | assert_eq!( |
|
| 619 | + | (v[0]["repository"].as_str(), v[0]["number"].as_i64(), v[1]["number"].as_i64()), |
|
| 620 | + | (Some("andy/demo"), Some(2), Some(1)), |
|
| 621 | + | "{v}" |
|
| 622 | + | ); |
|
| 623 | + | assert!(!v[0]["jobs"].as_array().unwrap().is_empty(), "with its jobs: {v}"); |
|
| 624 | + | let (_, body) = call(&h.app, "GET", &format!("/api/v1/user/flow/runs?before={}", v[0]["created_ms"]), Some("site"), None).await; |
|
| 625 | + | let older: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 626 | + | assert!(older.as_array().unwrap().iter().all(|r| r["number"] != 2), "before pages back: {older}"); |
|
| 627 | + | assert_eq!(call(&h.app, "GET", "/api/v1/user/flow/runs", None, None).await.0, StatusCode::UNAUTHORIZED); |
|
| 613 | 628 | assert_eq!(db::flow::jobs(&h.app.db, second.id).await.unwrap().len(), 3); |
|
| 614 | 629 | let (_, page) = call(&h.app, "GET", "/andy/demo/flow/2", None, None).await; |
|
| 615 | 630 | assert!(page.contains("started by andy"), "the run page says what started it"); |
|
| … | 6658 unchanged lines |
modified docs/spec.md +1 −1
| … | 491 unchanged lines |
||
| 492 | 492 | ||
| 493 | 493 | ### 12.1 API |
|
| 494 | 494 | ||
| 495 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 495 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 496 | 496 | ||
| 497 | 497 | ### 12.2 Webhooks |
|
| 498 | 498 | ||
| … | 396 unchanged lines |