//! Milestone 0.3 end to end: a proposal from a bookmark and from a fork, //! an atom-anchored comment, a seal-backed approval, requirements and a //! landing from the hub. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use http_body_util::BodyExt; use oxy_core::repo::Repo; use oxygenhub::config::Config; use oxygenhub::db; use oxygenhub::repos::RepoName; use oxygenhub::Shared; use std::path::Path; use tower::ServiceExt; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn write(root: &Path, rel: &str, content: &[u8]) { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, content).unwrap(); } struct Hub { app: Shared, base: String, _dir: tempfile::TempDir, } async fn hub() -> Hub { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); // The first account's setup code (oxygenhub#245). config.registration.setup_code = "test-setup".into(); config.auth.push_tokens = vec!["site".into()]; config.registration.mode = "open".into(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); // The stand-in webhook and assistant servers listen on 127.0.0.1 (#219). config.outbound.allow = vec!["http://127.0.0.1".into()]; let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } } struct Res { status: StatusCode, body: String, cookie: Option, } async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>, bearer: Option<&str>) -> Res { let mut req = Request::builder().method(method).uri(path); if let Some(c) = cookie { req = req.header(header::COOKIE, format!("oxyhub={c}")); } if let Some(b) = bearer { req = req.header(header::AUTHORIZATION, format!("Bearer {b}")); } let body = match form { Some(f) => { req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); Body::from(f.to_string()) } None => Body::empty(), }; let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); let status = res.status(); let cookie = res .headers() .get(header::SET_COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|v| v.split(';').next()) .and_then(|kv| kv.split_once('=')) .map(|(_, v)| v.to_string()); let bytes = res.into_body().collect().await.unwrap().to_bytes(); Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } } async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { send(app, "GET", path, cookie, None, None).await } async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { send(app, "POST", path, cookie, Some(form), None).await } /// POST a proposal action and follow its redirect back to the page, as a /// browser does (actions redirect so a reload never repeats them). async fn act(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { let mut req = Request::builder().method("POST").uri(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); if let Some(c) = cookie { req = req.header(header::COOKIE, format!("oxyhub={c}")); } let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(form.to_string())).unwrap()).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER, "{path} redirects back"); let to = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); get(app, &to, cookie).await } async fn register(app: &Shared, user: &str) -> String { let r = post( app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); r.cookie.unwrap() } fn csrf_of(page: &str) -> String { let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); page[i..].split('"').next().unwrap().to_string() } #[tokio::test(flavor = "multi_thread")] async fn propose_review_seal_and_land() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let bob = register(app, "bob").await; // A repository with main and a feature bookmark, pushed by Andy's client. let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.config.set("core.sign", "change").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\ntwo\n"); write(work.path(), ".oxyowners", b"* @andy\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); write(work.path(), "a.txt", b"one\ntwo\nthree\n"); client.snapshot("t").unwrap(); let feat = client.commit("add three", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); // The compare page offers the proposal to writers only (hub spec §6.3). let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await; assert!(cmp.body.contains("href=\"/andy/demo/proposals/new?source=feature&target=main\">Propose this"), "{}", cmp.body); assert!(!get(app, "/andy/demo/compare/main...feature", Some(&bob)).await.body.contains("Propose this")); assert!( !get(app, "/andy/demo/compare/feature...main", Some(&andy)).await.body.contains("Propose this"), "nothing to propose the other way" ); // Bob previews and opens a proposal feature → main. let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await; assert_eq!(preview.status, StatusCode::OK); assert!(preview.body.contains("1 change") && preview.body.contains("add three"), "{}", preview.body); let csrf = csrf_of(&preview.body); let r = post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=Add+three&body=Please&target=main&source=feature")) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await; assert_eq!(page.status, StatusCode::OK); assert!(page.body.contains("Add three") && page.body.contains("1 change"), "{}", page.body); assert!(page.body.contains("Please")); assert!(get(app, "/andy/demo/proposals", None).await.body.contains("#1")); let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await; assert!(cmp.body.contains("proposal #1 is open") && !cmp.body.contains("Propose this"), "the open proposal instead"); // A comment anchored to line 3 of a.txt lands in the thread on the files tab. let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await; assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body); let csrf_b = csrf_of(&files.body); let r = act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body); let c = db::proposals::comments(&app.db, 1).await.unwrap(); assert_eq!(c[0].anchor_change, feat.to_hex(), "anchored to the atom the feature change introduced"); // Live (oxygenhub#205): the page marks what updates itself; a comment // is heard on the proposal's topic; each part can be fetched alone. let demo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); let topic = oxygenhub::events::topic::proposal(demo.id, 1); let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; assert!(page.contains(&format!("data-live=\"{topic}\" data-fragment=\"timeline\"")), "the timeline is live"); assert!(page.contains(&format!("data-live=\"{topic} repo:{}\" data-fragment=\"landing\"", demo.id)), "landing hears checks too"); let csrf_c = csrf_of(&page); act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_c}&body=heard+live")).await; let heard = match app.events.live.since(0, std::slice::from_ref(&topic)) { oxygenhub::events::Replay::Events(e) => e.into_iter().map(|e| e.kind).collect::>(), oxygenhub::events::Replay::Reload => vec![], }; assert!(heard.contains(&"comment".to_string()), "{heard:?}"); let part = get(app, "/andy/demo/proposal/1?fragment=timeline", Some(&bob)).await; assert_eq!(part.status, StatusCode::OK); assert!( part.body.contains("heard live") && !part.body.contains("Landing") && !landing.body.contains("heard live"), "{}", landing.body); assert_eq!(get(app, "/andy/demo/proposal/1?fragment=nope", Some(&bob)).await.status, StatusCode::NOT_FOUND); // Requirements: one sealed approval and code owners. let settings = get(app, "/andy/demo/settings", Some(&andy)).await; let csrf_a = csrf_of(&settings.body); assert_eq!( post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1")) .await .status, StatusCode::OK ); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body); assert!(page.body.contains("code owners still to approve: andy"), "{}", page.body); // Bob cannot approve his own; Andy approves, which is pending until sealed. let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body); let r = act(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await; assert!(r.body.contains("cannot approve your own"), "{}", r.body); let r = act(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await; assert!(r.body.contains("seal pending"), "{}", r.body); assert!(r.body.contains("oxy seal --proposal")); // Andy links his key, seals the proposal's change and pushes the seal: the approval counts. let key = client.config.author_key.unwrap(); let identity = client.identity(&key).unwrap(); db::accounts::link_identity( &app.db, db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id, &key.to_hex(), &identity.name, &identity.email, &identity.encode(), ) .await .unwrap(); let seal = client.seal(&[feat]).unwrap(); client.push_seals("origin", &[seal]).unwrap(); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("approved and sealed"), "{}", page.body); assert!(page.body.contains("ready to land"), "{}", page.body); // The API agrees, and names the projected heads for `oxy proposal land`. let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await; assert_eq!(api.status, StatusCode::OK); let v: serde_json::Value = serde_json::from_str(&api.body).unwrap(); assert_eq!(v["landable"], true, "{}", api.body); assert_eq!(v["approvals"][0]["sealed"], true); assert_eq!(v["projected_heads"][0], feat.to_hex()); assert_eq!(v["changes"][0]["hash"], feat.to_hex()); // Land from the hub (no policy): main moves to the projected heads. let csrf_a = csrf_of(&page.body); let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await; assert!(r.body.contains("Landed."), "{}", r.body); let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap(); assert_eq!(heads, vec![feat]); assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1")); assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1 (web)")); // Landed, it still shows what it landed, by whom and how, and nothing holds it back. let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await; let v: serde_json::Value = serde_json::from_str(&api.body).unwrap(); assert_eq!(v["state"], "landed", "{}", api.body); assert_eq!(v["changes"][0]["hash"], feat.to_hex(), "{}", api.body); assert_eq!(v["blockers"], serde_json::json!([]), "{}", api.body); assert_eq!(v["landed"]["by"], "andy"); assert_eq!(v["landed"]["via"], "web"); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("by andy, web"), "{}", page.body); // oxygenhub#86: a landed card offering to delete the source bookmark, // the description edited in place from its ··· menu or the pencil. assert!(page.body.contains("Proposal landed") && page.body.contains("value=\"feature\""), "{}", page.body); assert!(page.body.contains("can be safely deleted"), "{}", page.body); assert!(page.body.contains("class=\"head-pencil\"") && page.body.contains("id=\"edit\"") && page.body.contains("Copy link")); assert!(!page.body.contains("Edit title, target and description"), "no disclosure at the bottom"); let csrf = csrf_of(&page.body); let r = post(app, "/andy/demo/bookmarks/delete", Some(&andy), &format!("_csrf={csrf}&bookmark=feature")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("Proposal landed") && !page.body.contains("can be safely deleted"), "gone, so not offered again"); // Lists by state through the API. let list = |q: &'static str| async move { let r = send(app, "GET", &format!("/api/v1/repos/andy/demo/proposals{q}"), None, None, Some("site")).await; serde_json::from_str::(&r.body).unwrap().as_array().unwrap().len() }; assert_eq!(list("").await, 0, "open ones only by default"); assert_eq!(list("?state=landed").await, 1); assert_eq!(list("?state=all").await, 1); // A proposal from a fork: a copy of the repository with the same id. let fork = RepoName::parse("bob/demo").unwrap(); app.repos.create(&fork, Some(client.id().unwrap())).unwrap(); db::repos::create(&app.db, "bob", "demo", "fork", hub_api::Visibility::Public).await.unwrap(); db::accounts::set_collaborator( &app.db, db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap().id, Some(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id), None, "admin", ) .await .unwrap(); client.config.set("remote.fork.url", &format!("{}/bob/demo", h.base)).unwrap(); client.config.set("remote.fork.token", "site").unwrap(); client.bookmark_switch("main", false).unwrap(); client.fetch("origin").unwrap(); client.bookmark_set("main", &[feat]).unwrap(); client.bookmark_create("fix", Some(&[feat])).unwrap(); client.bookmark_switch("fix", false).unwrap(); write(work.path(), "b.txt", b"b\n"); client.snapshot("t").unwrap(); let fix = client.commit("add b", &[]).unwrap(); assert!(client.push_bookmark("fork", "fix", false).unwrap().iter().all(|r| r.ok)); let preview = get(app, "/andy/demo/proposals/new?source=bob/demo:fix&target=main", Some(&bob)).await; assert!(preview.body.contains("add b"), "fork preview: {}", preview.body); let csrf = csrf_of(&preview.body); let r = post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+fork&target=main&source=bob/demo:fix")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let page = get(app, "/andy/demo/proposal/2?tab=changes", Some(&andy)).await; assert!(page.body.contains("add b"), "{}", page.body); assert!( app.repos.read(&repo, move |r, _| Ok(r.store().has(&fix))).await.unwrap(), "the fork's change was copied into the target's store" ); // Closing and reopening. let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body); let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await; assert!(r.body.contains(">closed<"), "{}", r.body); let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await; assert!(r.body.contains(">open<"), "{}", r.body); } /// A stand-in chat-completions endpoint that answers with a fixed review /// and keeps the body of each call. async fn mock_assistant(answer: &'static str) -> (String, std::sync::Arc>>) { use std::sync::{Arc, Mutex}; let seen: Arc>> = Arc::new(Mutex::new(vec![])); let s2 = seen.clone(); let router = axum::Router::new().route( "/v1/chat/completions", axum::routing::post(move |headers: axum::http::HeaderMap, body: String| { let s2 = s2.clone(); async move { let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); s2.lock().unwrap().push(format!("{auth} {body}")); axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": answer } }] })) } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen) } #[tokio::test(flavor = "multi_thread")] async fn an_assistant_reviews_a_proposal_via_the_person_who_asked() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let bob = register(app, "bob").await; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.config.set("core.sign", "change").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\ntwo\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); write(work.path(), "a.txt", b"one\ntwo\nthree\n"); client.snapshot("t").unwrap(); client.commit("add three", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await; let csrf_b = csrf_of(&preview.body); let r = post( app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf_b}&title=Add+three&body=Please&target=main&source=feature"), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); // Nothing to ask until the site has an assistant. let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; assert!(!page.contains("Ask Claude"), "{page}"); let (endpoint, seen) = mock_assistant("**Summary.** Adds a third line.\n\n- a.txt line 3: fine.").await; let admin = get(app, "/admin", Some(&andy)).await.body; let csrf_a = csrf_of(&admin); let ep = endpoint.replace(':', "%3A").replace('/', "%2F"); let r = post( app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf_a}&action=add&name=Claude&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=sk-test&enabled=1"), ) .await; assert!(r.body.contains("Claude added and answered"), "{}", r.body); seen.lock().unwrap().clear(); let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf_a}&action=prompt&prompt=Review+for+correctness+first.")).await; assert!(r.body.contains("Prompt saved."), "{}", r.body); // Claude drafts a description for a proposal not yet opened, and for // the open one; neither is saved until the person says so. let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await.body; assert!(page.contains("Draft with Claude"), "{page}"); let r = post( app, "/andy/demo/proposals/new/draft", Some(&bob), &format!("_csrf={}&title=Add+three&body=&target=main&source=feature&assistant=Claude", csrf_of(&page)), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); assert!(r.body.contains("rows=\"8\">**Summary.** Adds a third line.") && r.body.contains("value=\"Add three\""), "{}", r.body); { let calls = seen.lock().unwrap(); assert_eq!(calls.len(), 1); assert!( calls[0].contains("drafting the description") && calls[0].contains("+three") && calls[0].contains("Proposal: Add three"), "{}", calls[0] ); } let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; assert!(page.contains("Draft with Claude"), "{page}"); let r = post(app, "/andy/demo/proposal/1/draft", Some(&bob), &format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page))).await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); assert!( r.body.contains("tl-editform open") && r.body.contains("aria-label=\"Description (Markdown)\">**Summary.** Adds a third line."), "{}", r.body ); assert!(r.body.contains("nothing is saved until you update"), "{}", r.body); assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "the description is untouched"); assert_eq!(seen.lock().unwrap().len(), 2); seen.lock().unwrap().clear(); // The same as a stream for the page to show as it comes (#248): the // button says where, and the draft arrives in parts, then whole. let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; assert!(page.contains("data-stream=\"/andy/demo/proposal/1/draft/stream\""), "{page}"); let form = format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page)); let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form).await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); assert!(r.body.contains("event: part") && r.body.contains("event: done"), "{}", r.body); assert!(r.body.contains("Adds a third line."), "{}", r.body); assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "still nothing saved"); let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form.replace("Claude", "Nobody")).await; assert!(r.body.contains("event: error") && r.body.contains("No assistant called Nobody"), "{}", r.body); seen.lock().unwrap().clear(); // Andy approves; then Bob, the author, asks Claude. let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={}&verdict=approve&body=good", csrf_of(&page))).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; assert!(page.contains("Ask Claude"), "{page}"); let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=claude", csrf_of(&page))).await; assert!(r.body.contains("Asked Claude to review"), "{}", r.body); let mut reviews = vec![]; for _ in 0..100 { reviews = db::proposals::reviews(&app.db, 1).await.unwrap(); if reviews.iter().any(|r| r.via == "Claude") { break; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } let claude = reviews.iter().find(|r| r.via == "Claude").expect("the assistant's review arrived"); assert_eq!((claude.author.as_str(), claude.verdict.as_str()), ("bob", "comment")); assert!(claude.body.contains("Adds a third line")); { let calls = seen.lock().unwrap(); assert_eq!(calls.len(), 1); let c = &calls[0]; assert!(c.starts_with("Bearer sk-test "), "{c}"); assert!( c.contains("You are Claude") && c.contains("Review for correctness first."), "the system prompt and the site's prompt: {c}" ); assert!(c.contains("Proposal #1: Add three") && c.contains("a.txt (modified)") && c.contains("+three"), "the context: {c}"); } // Shown as Bob via Claude, with the event; Andy's approval still counts. let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; assert!( page.contains("bob ↳ Claude"), "{page}" ); assert!(page.contains("asked Claude to review") && page.contains("Adds a third line"), "{page}"); let p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap(); let loaded = oxygenhub::web::proposals::load(app, &repo, p).await.unwrap(); let v = oxygenhub::web::proposals::verdict(app, &loaded, 1).await.unwrap(); assert!(v.approvals.iter().any(|a| a.account == "andy"), "{:?}", v.approvals.iter().map(|a| &a.account).collect::>()); // Bob asks Claude for a summary (#190): it comes as a comment via Claude. assert!(page.contains("Summarise with Claude"), "{page}"); seen.lock().unwrap().clear(); let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=Claude&task=summary", csrf_of(&page))).await; assert!(r.body.contains("Asked Claude for a summary"), "{}", r.body); let mut comments = vec![]; for _ in 0..100 { comments = db::proposals::comments(&app.db, 1).await.unwrap(); if comments.iter().any(|c| c.via == "Claude") { break; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } let summary = comments.iter().find(|c| c.via == "Claude").expect("the assistant's summary arrived"); assert_eq!(summary.author, "bob"); assert!(summary.body.contains("Adds a third line")); { let calls = seen.lock().unwrap(); assert_eq!(calls.len(), 1); let c = &calls[0]; assert!( c.contains("called Oxygen") && c.contains("equivalent of a merge request or pull request") && c.contains("Please summarise the proposal"), "{c}" ); assert!(c.contains("Review for correctness first.") && c.contains("+three"), "the site's prompt and the context: {c}"); } let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; assert!(page.contains("asked Claude for a summary") && page.contains("wrote this for bob"), "{page}"); // The API lists the review via Claude and takes requests too. let r = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1/reviews", None, None, None).await; assert!(r.body.contains("\"via\":\"Claude\""), "{}", r.body); // Renaming the assistant renames it on the review and in the events. let admin = get(app, "/admin", Some(&andy)).await.body; let r = post( app, "/admin/assistants", Some(&andy), &format!( "_csrf={}&action=update&id={}&name=Claude+Code&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=&enabled=1", csrf_of(&admin), oxygenhub::assistants::list(&app.db, oxygenhub::levels::Scope::Site).await.unwrap()[0].id ), ) .await; assert!(r.body.contains("Claude Code updated."), "{}", r.body); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; assert!(page.contains("Claude Code") && page.contains("asked Claude Code to review"), "{page}"); assert!(!page.contains("Claude"), "{page}"); // A stranger cannot ask; an unknown assistant is refused. post(app, "/andy/demo/proposal/1/assistant", None, "_csrf=x&assistant=Claude").await; assert_eq!(seen.lock().unwrap().len(), 1, "no call for a stranger"); let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=HAL", csrf_of(&page))).await; assert!(r.body.contains("No assistant called HAL"), "{}", r.body); } /// #141: a proposal whose work reaches its target another way (a client's /// `oxy proposal land`, as a trust policy needs, or a merge pushed by hand) /// is marked landed by that push, not left open with nothing to land. #[tokio::test(flavor = "multi_thread")] async fn a_proposal_landed_by_a_push_is_marked_landed() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); write(work.path(), "a.txt", b"one\ntwo\n"); client.snapshot("t").unwrap(); let feat = client.commit("add two", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; let csrf = csrf_of(&preview.body); let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state, "open"); // The work goes onto main by a push, not by the Land button. client.bookmark_set("main", &[feat]).unwrap(); assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok)); let mut p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap(); for _ in 0..100 { if p.state == "landed" { break; } tokio::time::sleep(std::time::Duration::from_millis(50)).await; p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap(); } assert_eq!((p.state.as_str(), p.landed_via.as_str()), ("landed", "push"), "{p:?}"); } /// #140: a proposal is closed and reopened through the API, as the page's /// buttons do, by its author or someone with triage access. #[tokio::test(flavor = "multi_thread")] async fn a_proposal_is_closed_and_reopened_through_the_api() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); write(work.path(), "a.txt", b"one\ntwo\n"); client.snapshot("t").unwrap(); client.commit("add two", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; let csrf = csrf_of(&preview.body); let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let patch = |json: &'static str, bearer: Option<&'static str>| async move { let mut req = Request::builder().method("PATCH").uri("/api/v1/repos/andy/demo/proposals/1").header(header::CONTENT_TYPE, "application/json"); if let Some(b) = bearer { req = req.header(header::AUTHORIZATION, format!("Bearer {b}")); } let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(json)).unwrap()).await.unwrap(); let st = res.status(); (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) }; let state = || async { db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state }; // The site token acts for the first administrator, andy. let (st, body) = patch(r#"{"state":"closed"}"#, Some("site")).await; assert_eq!(st, StatusCode::OK, "{body}"); assert_eq!(state().await, "closed"); assert_eq!(patch(r#"{"state":"closed"}"#, Some("site")).await.0, StatusCode::OK, "closing a closed proposal changes nothing"); let (st, _) = patch(r#"{"state":"open"}"#, Some("site")).await; assert_eq!(st, StatusCode::OK); assert_eq!(state().await, "open"); assert_eq!(patch(r#"{"state":"landed"}"#, Some("site")).await.0, StatusCode::BAD_REQUEST, "landing has its own endpoint"); assert_ne!(patch(r#"{"state":"closed"}"#, None).await.0, StatusCode::OK, "not without a token"); assert_eq!(state().await, "open"); } /// Landing over checks (#261): with required checks not passed, a /// maintainer may land now with a reason, recorded and audited; a writer /// may not, nor anyone without a reason. #[tokio::test(flavor = "multi_thread")] async fn a_maintainer_lands_over_checks_with_a_reason() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let bob = register(app, "bob").await; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id; db::accounts::set_collaborator(&app.db, row.id, Some(bob_id), None, "write").await.unwrap(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); write(work.path(), "b.txt", b"b\n"); client.snapshot("t").unwrap(); client.commit("the fix", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; let r = post( app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={}&title=Fix&body=&target=main&source=feature", csrf_of(&preview.body)), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); db::hooks::set_required_checks(&app.db, row.id, &["flow/test".to_string()]).await.unwrap(); // The checks block it; the maintainer is offered landing over them. let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("required checks not passed") && page.body.contains("Land now, over the checks"), "{}", page.body); let csrf = csrf_of(&page.body); let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}")).await; assert!(r.body.contains("Cannot land: required checks not passed"), "plain land is refused: {}", r.body); assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); // A writer is not offered it and cannot do it. let bob_page = get(app, "/andy/demo/proposal/1", Some(&bob)).await; assert!(!bob_page.body.contains("Land now, over the checks")); let r = post(app, "/andy/demo/proposal/1/land", Some(&bob), &format!("_csrf={}&over_checks=please", csrf_of(&bob_page.body))).await; assert_eq!(r.status, StatusCode::NOT_FOUND, "a writer cannot land over checks"); assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); // No reason, no landing. let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}&over_checks=+")).await; assert!(r.body.contains("needs a reason"), "{}", r.body); assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); // With one: landed, and it says so. let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}&over_checks=the+runners+are+down")).await; assert!(r.body.contains("over checks: the runners are down"), "the landing says so: {}", r.body); let p = db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap(); assert_eq!(p.state, "landed"); assert!(p.landed_via.contains("over checks: the runners are down"), "{}", p.landed_via); let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); assert!(audit .iter() .any(|a| a.action == "proposal.land_over_checks" && a.detail.contains("the runners are down") && a.detail.contains("flow/test"))); } /// A landed proposal's runs that no runner has started are cancelled /// (#263): they hold the queue ahead of the target's own run. A running /// one finishes; another bookmark's run is untouched. #[tokio::test(flavor = "multi_thread")] async fn landing_cancels_the_proposals_unstarted_runs() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); write(work.path(), "b.txt", b"b\n"); client.snapshot("t").unwrap(); client.commit("the fix", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; let r = post( app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={}&title=Fix&body=&target=main&source=feature", csrf_of(&preview.body)), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); // Runs as the queue holds them: the source's push runs (one queued, // one running), the proposal's own, and another bookmark's. let run = |bookmark: &'static str, trigger: &'static str| { let app = app.clone(); let repo_id = row.id; async move { let job = db::flow::NewJob { name: "test".into(), depth: 0, depends: vec![], spec: "{}".into(), sandbox: "{}".into(), runtime: "process".into(), need_disk_mib: None, }; let new = db::flow::NewRun { repo_id, bookmark, state: "s", heads: "h", pipeline_hash: "", actor: "andy", trigger, error: None }; db::flow::create_run(&app.db, new, &[job]).await.unwrap() } }; let queued = run("feature", "push").await; let running = run("feature", "push").await; let own = run("proposal/1", "proposal").await; let other = run("other", "push").await; sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = $1").bind(running).execute(&app.db).await.unwrap(); sqlx::query("UPDATE flow_jobs SET status = 'running' WHERE run_id = $1").bind(running).execute(&app.db).await.unwrap(); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={}", csrf_of(&page.body))).await; assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "landed", "{}", r.body); let status = |id: i64| { let app = app.clone(); async move { let run = db::flow::run_by_id(&app.db, id).await.unwrap().unwrap(); let jobs = db::flow::jobs(&app.db, id).await.unwrap(); (run.status, jobs[0].error.clone()) } }; for id in [queued, own] { let (st, why) = status(id).await; assert_eq!(st, "cancelled"); assert_eq!(why, "moot: proposal #1 landed"); } assert_eq!(status(running).await.0, "running", "a started run finishes"); assert_eq!(status(other).await.0, "queued", "another bookmark's run is untouched"); } /// Land when checks pass (#261): set by someone who may land, it lands as /// them once the required check passes on the projected state; a failing /// check turns it off, with a note; it can be turned off. #[tokio::test(flavor = "multi_thread")] async fn a_proposal_lands_itself_when_its_checks_pass() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let row = &db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); for (bm, file) in [("one", "b.txt"), ("two", "c.txt")] { client.bookmark_create(bm, Some(&[base])).unwrap(); client.bookmark_switch(bm, false).unwrap(); write(work.path(), file, b"x\n"); client.snapshot("t").unwrap(); client.commit(&format!("on {bm}"), &[]).unwrap(); assert!(client.push_bookmark("origin", bm, false).unwrap().iter().all(|r| r.ok)); let preview = get(app, &format!("/andy/demo/proposals/new?source={bm}&target=main"), Some(&andy)).await; let r = post( app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={}&title={bm}&body=&target=main&source={bm}", csrf_of(&preview.body)), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); } db::hooks::set_required_checks(&app.db, row.id, &["flow/test".to_string()]).await.unwrap(); let check = |number: i64, status: &'static str| async move { let p = db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap(); let report = db::hooks::CheckReport { state: &p.projected_state, name: "flow/test", status, summary: "", url: "", reporter: None, seal: "" }; db::hooks::upsert_check(&app.db, row.id, report).await.unwrap(); oxygenhub::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": { "state": p.projected_state } })).await; }; let state_of = |number: i64| async move { for _ in 0..100 { let p = db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap(); if p.state != "open" || p.auto_land_by.is_empty() { return p; } tokio::time::sleep(std::time::Duration::from_millis(50)).await; } db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap() }; // #1: set while its check is pending; it waits, then lands as Andy. let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("LAND WHEN CHECKS PASS"), "{}", page.body); let r = act(app, "/andy/demo/proposal/1/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; assert!(r.body.contains("lands when its checks pass (set by"), "{}", r.body); assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open", "the check has not passed"); check(1, "running").await; tokio::time::sleep(std::time::Duration::from_millis(200)).await; assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open", "still running"); check(1, "passed").await; let p = state_of(1).await; assert_eq!((p.state.as_str(), p.landed_by.as_str()), ("landed", "andy")); assert!(p.landed_via.contains("when its checks passed (set by andy)"), "{}", p.landed_via); // #2: a failing check turns it off, with a note; it stays open. let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; check(2, "failed").await; let p = state_of(2).await; assert_eq!((p.state.as_str(), p.auto_land_by.as_str()), ("open", "")); let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; assert!(page.body.contains("turned off landing when its checks pass") && page.body.contains("flow/test did not pass"), "{}", page.body); // Set again and turned off by hand. act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; let r = act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=0", csrf_of(&page.body))).await; assert!(r.body.contains("will not land by itself"), "{}", r.body); assert!(db::proposals::get(&app.db, row.id, 2).await.unwrap().unwrap().auto_land_by.is_empty()); } /// Metal Gear 2's finding: a landed proposal's "Closes #N" closed nothing, /// because only changes a push imports were read. Changes that arrive on /// the default bookmark close issues however they get there. #[tokio::test(flavor = "multi_thread")] async fn closes_in_a_landed_change_closes_the_issue() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let json = |method: &'static str, path: String, body: &'static str| async move { let res = oxygenhub::router(app.clone()) .oneshot( Request::builder() .method(method) .uri(path) .header(header::AUTHORIZATION, "Bearer site") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body)) .unwrap(), ) .await .unwrap(); let st = res.status(); (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) }; for t in ["one", "two"] { let body: &'static str = if t == "one" { r#"{"title":"one"}"# } else { r#"{"title":"two"}"# }; let (st, b) = json("POST", "/api/v1/repos/andy/demo/issues".into(), body).await; assert_eq!(st, StatusCode::CREATED, "{b}"); } let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); write(work.path(), "b.txt", b"b\n"); client.snapshot("t").unwrap(); client.commit("add b\n\nCloses #1", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); let state = |n: i64| async move { db::issues::get(&app.db, 1, n).await.unwrap().unwrap().state }; assert_eq!(state(1).await, "open", "a push to another bookmark closes nothing"); // Landed on the hub: #1 closes. let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; let csrf = csrf_of(&preview.body); let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=B&body=&target=main&source=feature")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let number = db::proposals::list(&app.db, 1, &["open"]).await.unwrap()[0].number; let (st, b) = json("POST", format!("/api/v1/repos/andy/demo/proposals/{number}/land"), "{}").await; assert_eq!(st, StatusCode::OK, "{b}"); assert_eq!(state(1).await, "closed", "landing closes what the change says"); // A client moving main to a change the hub already holds: #2 closes. client.bookmark_create("other", Some(&[base])).unwrap(); client.bookmark_switch("other", false).unwrap(); write(work.path(), "c.txt", b"c\n"); client.snapshot("t").unwrap(); let c = client.commit("add c, closes #2", &[]).unwrap(); assert!(client.push_bookmark("origin", "other", false).unwrap().iter().all(|r| r.ok)); assert_eq!(state(2).await, "open"); let (_, list) = json("GET", "/api/v1/repos/andy/demo/bookmarks".into(), "").await; let list: Vec = serde_json::from_str(&list).unwrap(); let main = list.iter().find(|b| b["name"] == "main").unwrap(); let mut heads: Vec = main["heads"].as_array().unwrap().iter().map(|h| oxy_core::hash::ChangeHash::from_hex(h.as_str().unwrap()).unwrap()).collect(); client.fetch("origin").unwrap(); heads.push(c); client.bookmark_set("main", &heads).unwrap(); assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok)); let mut s2 = state(2).await; for _ in 0..100 { if s2 == "closed" { break; } tokio::time::sleep(std::time::Duration::from_millis(30)).await; s2 = state(2).await; } assert_eq!(s2, "closed", "a change arriving by push closes it, though the hub held it already"); } /// A stand-in that refuses a prompt longer than `limit` bytes as a provider /// does when it overflows the model, and keeps each call's length. async fn mock_limited(limit: usize) -> (String, std::sync::Arc>>) { use std::sync::{Arc, Mutex}; let seen: Arc>> = Arc::new(Mutex::new(vec![])); let s2 = seen.clone(); let router = axum::Router::new().route( "/v1/chat/completions", axum::routing::post(move |body: String| { let s2 = s2.clone(); async move { s2.lock().unwrap().push(body.len()); if body.len() > limit { let msg = format!("This model's maximum context length is {limit} tokens. Please reduce the length of the messages."); return (StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": { "message": msg } }))); } ( StatusCode::OK, axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": "Small draft." } }] })), ) } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen) } /// A proposal too large for the model (oxygenhub#248): the provider refuses /// it, the hub asks again with a third of the room, and the answer comes. #[tokio::test(flavor = "multi_thread")] async fn a_proposal_too_large_for_the_model_is_asked_again_smaller() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); // A big change: 200 lines of 60 characters, about 12 KB of diff. let big: String = (0..200).map(|i| format!("{i:04} {}\n", "x".repeat(55))).collect(); write(work.path(), "big.txt", big.as_bytes()); client.snapshot("t").unwrap(); client.commit("a big file", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); let (endpoint, seen) = mock_limited(6_000).await; let admin = get(app, "/admin", Some(&andy)).await.body; let ep = endpoint.replace(':', "%3A").replace('/', "%2F"); let r = post( app, "/admin/assistants", Some(&andy), &format!( "_csrf={}&action=add&name=Small&endpoint={ep}&api=chat-completions&model=small-1&token=sk-test&enabled=1&budget=12000", csrf_of(&admin) ), ) .await; assert!(r.body.contains("Small added and answered"), "{}", r.body); seen.lock().unwrap().clear(); let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await.body; let r = post( app, "/andy/demo/proposals/new/draft", Some(&andy), &format!("_csrf={}&title=Big&body=&target=main&source=feature&assistant=Small", csrf_of(&page)), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); assert!(r.body.contains("Small draft."), "{}", r.body); let calls = seen.lock().unwrap().clone(); assert_eq!(calls.len(), 2, "refused once, then asked smaller: {calls:?}"); assert!(calls[0] > 6_000 && calls[1] <= 6_000, "{calls:?}"); }