| de505df5 Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
use axum::body::Body; |
| 6 |
use axum::http::{header, Request, StatusCode}; |
| 7 |
use http_body_util::BodyExt; |
| 8 |
use oxy_core::repo::Repo; |
| 9 |
use oxygenhub::config::Config; |
| 10 |
use oxygenhub::db; |
| 11 |
use oxygenhub::repos::RepoName; |
| 12 |
use oxygenhub::Shared; |
| 13 |
use std::path::Path; |
| 14 |
use tower::ServiceExt; |
| 15 |
|
| 16 |
fn hermetic() { |
| 17 |
static ONCE: std::sync::Once = std::sync::Once::new(); |
| 18 |
static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
| 19 |
ONCE.call_once(|| { |
| 20 |
let d = tempfile::tempdir().unwrap(); |
| 21 |
std::env::set_var("OXY_HOME", d.path()); |
| 22 |
DIR.set(d).ok(); |
| 23 |
}); |
| 24 |
} |
| 25 |
|
| 26 |
fn write(root: &Path, rel: &str, content: &[u8]) { |
| 27 |
let p = root.join(rel); |
| 28 |
std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
| 29 |
std::fs::write(p, content).unwrap(); |
| 30 |
} |
| 31 |
|
| 32 |
struct Hub { |
| 33 |
app: Shared, |
| 34 |
base: String, |
| 35 |
_dir: tempfile::TempDir, |
| 36 |
} |
| 37 |
|
| 38 |
async fn hub() -> Hub { |
| 39 |
hermetic(); |
| 40 |
let dir = tempfile::tempdir().unwrap(); |
| 41 |
let mut config = Config::load(dir.path()).unwrap(); |
| c8684d44 Andy McCall |
42 |
|
| 43 |
config.registration.setup_code = "test-setup".into(); |
| de505df5 Andy McCall ↶ |
44 |
config.auth.push_tokens = vec!["site".into()]; |
| 45 |
config.registration.mode = "open".into(); |
| 46 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 47 |
let port = listener.local_addr().unwrap().port(); |
| 48 |
config.server.base_url = format!("http://127.0.0.1:{port}"); |
| 9a777fec Andy McCall ↶ |
49 |
|
| 50 |
config.outbound.allow = vec!["http://127.0.0.1".into()]; |
| de505df5 Andy McCall ↶ |
51 |
let app = oxygenhub::build(config).await.unwrap(); |
| 52 |
let router = oxygenhub::router(app.clone()); |
| 53 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 54 |
Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
| 55 |
} |
| 56 |
|
| 57 |
struct Res { |
| 58 |
status: StatusCode, |
| 59 |
body: String, |
| 60 |
cookie: Option<String>, |
| 61 |
} |
| 62 |
|
| 63 |
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>, bearer: Option<&str>) -> Res { |
| 64 |
let mut req = Request::builder().method(method).uri(path); |
| 65 |
if let Some(c) = cookie { |
| 66 |
req = req.header(header::COOKIE, format!("oxyhub={c}")); |
| 67 |
} |
| 68 |
if let Some(b) = bearer { |
| 69 |
req = req.header(header::AUTHORIZATION, format!("Bearer {b}")); |
| 70 |
} |
| 71 |
let body = match form { |
| 72 |
Some(f) => { |
| 73 |
req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
| 74 |
Body::from(f.to_string()) |
| 75 |
} |
| 76 |
None => Body::empty(), |
| 77 |
}; |
| 78 |
let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
| 79 |
let status = res.status(); |
| 80 |
let cookie = res |
| 81 |
.headers() |
| 82 |
.get(header::SET_COOKIE) |
| 83 |
.and_then(|v| v.to_str().ok()) |
| 84 |
.and_then(|v| v.split(';').next()) |
| 85 |
.and_then(|kv| kv.split_once('=')) |
| 86 |
.map(|(_, v)| v.to_string()); |
| 87 |
let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
| 88 |
Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } |
| 89 |
} |
| 90 |
|
| 91 |
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
| 92 |
send(app, "GET", path, cookie, None, None).await |
| 93 |
} |
| 94 |
|
| 95 |
async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
| 96 |
send(app, "POST", path, cookie, Some(form), None).await |
| 97 |
} |
| 98 |
|
| a1b98695 Andy McCall ↶ |
99 |
|
| 100 |
|
| 101 |
async fn act(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
| 102 |
let mut req = Request::builder().method("POST").uri(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
| 103 |
if let Some(c) = cookie { |
| 104 |
req = req.header(header::COOKIE, format!("oxyhub={c}")); |
| 105 |
} |
| 106 |
let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(form.to_string())).unwrap()).await.unwrap(); |
| 107 |
assert_eq!(res.status(), StatusCode::SEE_OTHER, "{path} redirects back"); |
| 108 |
let to = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
| 109 |
get(app, &to, cookie).await |
| 110 |
} |
| 111 |
|
| de505df5 Andy McCall ↶ |
112 |
async fn register(app: &Shared, user: &str) -> String { |
| c8684d44 Andy McCall |
113 |
let r = post( |
| 114 |
app, |
| 115 |
"/register", |
| 116 |
None, |
| 117 |
&format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), |
| 118 |
) |
| 119 |
.await; |
| de505df5 Andy McCall ↶ |
120 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 121 |
r.cookie.unwrap() |
| 122 |
} |
| 123 |
|
| 124 |
fn csrf_of(page: &str) -> String { |
| 125 |
let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
| 126 |
page[i..].split('"').next().unwrap().to_string() |
| 127 |
} |
| 128 |
|
| 129 |
#[tokio::test(flavor = "multi_thread")] |
| 130 |
async fn propose_review_seal_and_land() { |
| 131 |
let h = hub().await; |
| 132 |
let app = &h.app; |
| 133 |
let andy = register(app, "andy").await; |
| 134 |
let bob = register(app, "bob").await; |
| 135 |
|
| 136 |
|
| 137 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 138 |
app.repos.create(&repo, None).unwrap(); |
| 139 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 140 |
let work = tempfile::tempdir().unwrap(); |
| 141 |
let mut client = Repo::init(work.path()).unwrap(); |
| 142 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 143 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 144 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 145 |
client.config.set("core.sign", "change").unwrap(); |
| 146 |
client.save_config().unwrap(); |
| 147 |
write(work.path(), "a.txt", b"one\ntwo\n"); |
| 148 |
write(work.path(), ".oxyowners", b"* @andy\n"); |
| 149 |
client.snapshot("t").unwrap(); |
| 150 |
let base = client.commit("base", &[]).unwrap(); |
| 151 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 152 |
client.bookmark_create("feature", Some(&[base])).unwrap(); |
| 153 |
client.bookmark_switch("feature", false).unwrap(); |
| 154 |
write(work.path(), "a.txt", b"one\ntwo\nthree\n"); |
| 155 |
client.snapshot("t").unwrap(); |
| 156 |
let feat = client.commit("add three", &[]).unwrap(); |
| 157 |
assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
| 158 |
|
| 3d1e74cd Andy McCall ↶ |
159 |
|
| 160 |
let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await; |
| 161 |
assert!(cmp.body.contains("href=\"/andy/demo/proposals/new?source=feature&target=main\">Propose this"), "{}", cmp.body); |
| 162 |
assert!(!get(app, "/andy/demo/compare/main...feature", Some(&bob)).await.body.contains("Propose this")); |
| 163 |
assert!( |
| 164 |
!get(app, "/andy/demo/compare/feature...main", Some(&andy)).await.body.contains("Propose this"), |
| 165 |
"nothing to propose the other way" |
| 166 |
); |
| 167 |
|
| de505df5 Andy McCall ↶ |
168 |
|
| 169 |
let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await; |
| 170 |
assert_eq!(preview.status, StatusCode::OK); |
| 171 |
assert!(preview.body.contains("1 change") && preview.body.contains("add three"), "{}", preview.body); |
| 172 |
let csrf = csrf_of(&preview.body); |
| 173 |
let r = |
| 174 |
post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=Add+three&body=Please&target=main&source=feature")) |
| 175 |
.await; |
| 176 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 177 |
let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await; |
| 178 |
assert_eq!(page.status, StatusCode::OK); |
| 179 |
assert!(page.body.contains("Add three") && page.body.contains("1 change"), "{}", page.body); |
| 180 |
assert!(page.body.contains("Please")); |
| 181 |
assert!(get(app, "/andy/demo/proposals", None).await.body.contains("#1")); |
| 3d1e74cd Andy McCall ↶ |
182 |
let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await; |
| 183 |
assert!(cmp.body.contains("proposal #1 is open") && !cmp.body.contains("Propose this"), "the open proposal instead"); |
| de505df5 Andy McCall ↶ |
184 |
|
| 185 |
|
| 186 |
let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await; |
| 187 |
assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body); |
| 188 |
let csrf_b = csrf_of(&files.body); |
| a1b98695 Andy McCall ↶ |
189 |
let r = act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await; |
| de505df5 Andy McCall ↶ |
190 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 191 |
assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body); |
| 192 |
let c = db::proposals::comments(&app.db, 1).await.unwrap(); |
| 193 |
assert_eq!(c[0].anchor_change, feat.to_hex(), "anchored to the atom the feature change introduced"); |
| 194 |
|
| b5a0a69b Andy McCall ↶ |
195 |
|
| 196 |
|
| 197 |
let demo = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
| 198 |
let topic = oxygenhub::events::topic::proposal(demo.id, 1); |
| 199 |
let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
| 200 |
assert!(page.contains(&format!("data-live=\"{topic}\" data-fragment=\"timeline\"")), "the timeline is live"); |
| 201 |
assert!(page.contains(&format!("data-live=\"{topic} repo:{}\" data-fragment=\"landing\"", demo.id)), "landing hears checks too"); |
| 202 |
let csrf_c = csrf_of(&page); |
| 203 |
act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_c}&body=heard+live")).await; |
| 33d98147 Andy McCall |
204 |
let heard = match app.bus.live.since(0, std::slice::from_ref(&topic)) { |
| b5a0a69b Andy McCall ↶ |
205 |
oxygenhub::events::Replay::Events(e) => e.into_iter().map(|e| e.kind).collect::<Vec<_>>(), |
| 206 |
oxygenhub::events::Replay::Reload => vec![], |
| 207 |
}; |
| 208 |
assert!(heard.contains(&"comment".to_string()), "{heard:?}"); |
| 209 |
let part = get(app, "/andy/demo/proposal/1?fragment=timeline", Some(&bob)).await; |
| 210 |
assert_eq!(part.status, StatusCode::OK); |
| 211 |
assert!( |
| 212 |
part.body.contains("heard live") && !part.body.contains("<html") && !part.body.contains("Review this proposal"), |
| 213 |
"{}", |
| 214 |
part.body |
| 215 |
); |
| 216 |
let landing = get(app, "/andy/demo/proposal/1?fragment=landing", Some(&bob)).await; |
| 217 |
assert!(landing.body.contains("<h3>Landing</h3>") && !landing.body.contains("heard live"), "{}", landing.body); |
| 218 |
assert_eq!(get(app, "/andy/demo/proposal/1?fragment=nope", Some(&bob)).await.status, StatusCode::NOT_FOUND); |
| 219 |
|
| de505df5 Andy McCall ↶ |
220 |
|
| 221 |
let settings = get(app, "/andy/demo/settings", Some(&andy)).await; |
| 222 |
let csrf_a = csrf_of(&settings.body); |
| 223 |
assert_eq!( |
| 224 |
post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1")) |
| 225 |
.await |
| 226 |
.status, |
| c7067c7b Andy McCall |
227 |
StatusCode::SEE_OTHER, |
| 228 |
"back to Settings › Landing rules" |
| de505df5 Andy McCall ↶ |
229 |
); |
| 230 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
| 231 |
assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body); |
| 232 |
assert!(page.body.contains("code owners still to approve: andy"), "{}", page.body); |
| 233 |
|
| 234 |
|
| 235 |
let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body); |
| a1b98695 Andy McCall ↶ |
236 |
let r = act(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await; |
| de505df5 Andy McCall ↶ |
237 |
assert!(r.body.contains("cannot approve your own"), "{}", r.body); |
| a1b98695 Andy McCall ↶ |
238 |
let r = act(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await; |
| de505df5 Andy McCall ↶ |
239 |
assert!(r.body.contains("seal pending"), "{}", r.body); |
| 240 |
assert!(r.body.contains("oxy seal --proposal")); |
| 241 |
|
| 242 |
|
| 243 |
let key = client.config.author_key.unwrap(); |
| 244 |
let identity = client.identity(&key).unwrap(); |
| 245 |
db::accounts::link_identity( |
| 246 |
&app.db, |
| 247 |
db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id, |
| 248 |
&key.to_hex(), |
| 249 |
&identity.name, |
| 250 |
&identity.email, |
| 251 |
&identity.encode(), |
| 252 |
) |
| 253 |
.await |
| 254 |
.unwrap(); |
| 255 |
let seal = client.seal(&[feat]).unwrap(); |
| 256 |
client.push_seals("origin", &[seal]).unwrap(); |
| 257 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
| 258 |
assert!(page.body.contains("approved and sealed"), "{}", page.body); |
| 259 |
assert!(page.body.contains("ready to land"), "{}", page.body); |
| 260 |
|
| 261 |
let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await; |
| 262 |
assert_eq!(api.status, StatusCode::OK); |
| 263 |
let v: serde_json::Value = serde_json::from_str(&api.body).unwrap(); |
| 264 |
assert_eq!(v["landable"], true, "{}", api.body); |
| 265 |
assert_eq!(v["approvals"][0]["sealed"], true); |
| 266 |
assert_eq!(v["projected_heads"][0], feat.to_hex()); |
| 267 |
assert_eq!(v["changes"][0]["hash"], feat.to_hex()); |
| 268 |
|
| 269 |
|
| 270 |
let csrf_a = csrf_of(&page.body); |
| a1b98695 Andy McCall ↶ |
271 |
let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await; |
| de505df5 Andy McCall ↶ |
272 |
assert!(r.body.contains("Landed."), "{}", r.body); |
| 273 |
let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap(); |
| 274 |
assert_eq!(heads, vec![feat]); |
| 275 |
assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1")); |
| 0b20f1ec Andy McCall ↶ |
276 |
assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1 (web)")); |
| 277 |
|
| 278 |
let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await; |
| 279 |
let v: serde_json::Value = serde_json::from_str(&api.body).unwrap(); |
| 280 |
assert_eq!(v["state"], "landed", "{}", api.body); |
| 281 |
assert_eq!(v["changes"][0]["hash"], feat.to_hex(), "{}", api.body); |
| 282 |
assert_eq!(v["blockers"], serde_json::json!([]), "{}", api.body); |
| 283 |
assert_eq!(v["landed"]["by"], "andy"); |
| 284 |
assert_eq!(v["landed"]["via"], "web"); |
| 285 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
| 286 |
assert!(page.body.contains("by <a href=\"/andy\">andy</a>, web"), "{}", page.body); |
| 4f37afcc Andy McCall ↶ |
287 |
|
| 288 |
|
| 289 |
assert!(page.body.contains("Proposal landed") && page.body.contains("value=\"feature\""), "{}", page.body); |
| 290 |
assert!(page.body.contains("can be safely deleted"), "{}", page.body); |
| 291 |
assert!(page.body.contains("class=\"head-pencil\"") && page.body.contains("id=\"edit\"") && page.body.contains("Copy link")); |
| 292 |
assert!(!page.body.contains("Edit title, target and description</summary>"), "no disclosure at the bottom"); |
| 293 |
let csrf = csrf_of(&page.body); |
| 294 |
let r = post(app, "/andy/demo/bookmarks/delete", Some(&andy), &format!("_csrf={csrf}&bookmark=feature")).await; |
| 295 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 296 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
| 297 |
assert!(page.body.contains("Proposal landed") && !page.body.contains("can be safely deleted"), "gone, so not offered again"); |
| 0b20f1ec Andy McCall ↶ |
298 |
|
| 299 |
let list = |q: &'static str| async move { |
| 300 |
let r = send(app, "GET", &format!("/api/v1/repos/andy/demo/proposals{q}"), None, None, Some("site")).await; |
| 301 |
serde_json::from_str::<serde_json::Value>(&r.body).unwrap().as_array().unwrap().len() |
| 302 |
}; |
| 303 |
assert_eq!(list("").await, 0, "open ones only by default"); |
| 304 |
assert_eq!(list("?state=landed").await, 1); |
| 305 |
assert_eq!(list("?state=all").await, 1); |
| de505df5 Andy McCall ↶ |
306 |
|
| 307 |
|
| 308 |
let fork = RepoName::parse("bob/demo").unwrap(); |
| 309 |
app.repos.create(&fork, Some(client.id().unwrap())).unwrap(); |
| 310 |
db::repos::create(&app.db, "bob", "demo", "fork", hub_api::Visibility::Public).await.unwrap(); |
| 311 |
db::accounts::set_collaborator( |
| 312 |
&app.db, |
| 313 |
db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap().id, |
| 314 |
Some(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id), |
| 315 |
None, |
| 316 |
"admin", |
| 317 |
) |
| 318 |
.await |
| 319 |
.unwrap(); |
| 320 |
client.config.set("remote.fork.url", &format!("{}/bob/demo", h.base)).unwrap(); |
| 321 |
client.config.set("remote.fork.token", "site").unwrap(); |
| 322 |
client.bookmark_switch("main", false).unwrap(); |
| 323 |
client.fetch("origin").unwrap(); |
| 324 |
client.bookmark_set("main", &[feat]).unwrap(); |
| 325 |
client.bookmark_create("fix", Some(&[feat])).unwrap(); |
| 326 |
client.bookmark_switch("fix", false).unwrap(); |
| 327 |
write(work.path(), "b.txt", b"b\n"); |
| 328 |
client.snapshot("t").unwrap(); |
| 329 |
let fix = client.commit("add b", &[]).unwrap(); |
| 330 |
assert!(client.push_bookmark("fork", "fix", false).unwrap().iter().all(|r| r.ok)); |
| 331 |
let preview = get(app, "/andy/demo/proposals/new?source=bob/demo:fix&target=main", Some(&bob)).await; |
| 332 |
assert!(preview.body.contains("add b"), "fork preview: {}", preview.body); |
| 333 |
let csrf = csrf_of(&preview.body); |
| 334 |
let r = |
| 335 |
post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+fork&target=main&source=bob/demo:fix")).await; |
| 336 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 337 |
let page = get(app, "/andy/demo/proposal/2?tab=changes", Some(&andy)).await; |
| 338 |
assert!(page.body.contains("add b"), "{}", page.body); |
| 339 |
assert!( |
| 340 |
app.repos.read(&repo, move |r, _| Ok(r.store().has(&fix))).await.unwrap(), |
| 341 |
"the fork's change was copied into the target's store" |
| 342 |
); |
| 343 |
|
| 344 |
let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body); |
| a1b98695 Andy McCall ↶ |
345 |
let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await; |
| de505df5 Andy McCall ↶ |
346 |
assert!(r.body.contains(">closed<"), "{}", r.body); |
| a1b98695 Andy McCall ↶ |
347 |
let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await; |
| de505df5 Andy McCall ↶ |
348 |
assert!(r.body.contains(">open<"), "{}", r.body); |
| 349 |
} |
| 7d2eb590 Andy McCall ↶ |
350 |
|
| 351 |
|
| 352 |
|
| 353 |
async fn mock_assistant(answer: &'static str) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) { |
| 354 |
use std::sync::{Arc, Mutex}; |
| 355 |
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![])); |
| 356 |
let s2 = seen.clone(); |
| 357 |
let router = axum::Router::new().route( |
| 358 |
"/v1/chat/completions", |
| 359 |
axum::routing::post(move |headers: axum::http::HeaderMap, body: String| { |
| 360 |
let s2 = s2.clone(); |
| 361 |
async move { |
| 362 |
let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
| 363 |
s2.lock().unwrap().push(format!("{auth} {body}")); |
| 364 |
axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": answer } }] })) |
| 365 |
} |
| 366 |
}), |
| 367 |
); |
| 368 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 369 |
let port = listener.local_addr().unwrap().port(); |
| 370 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 371 |
(format!("http://127.0.0.1:{port}/v1/chat/completions"), seen) |
| 372 |
} |
| 373 |
|
| 374 |
#[tokio::test(flavor = "multi_thread")] |
| 375 |
async fn an_assistant_reviews_a_proposal_via_the_person_who_asked() { |
| 376 |
let h = hub().await; |
| 377 |
let app = &h.app; |
| 378 |
let andy = register(app, "andy").await; |
| 379 |
let bob = register(app, "bob").await; |
| 380 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 381 |
app.repos.create(&repo, None).unwrap(); |
| 382 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 383 |
let work = tempfile::tempdir().unwrap(); |
| 384 |
let mut client = Repo::init(work.path()).unwrap(); |
| 385 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 386 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 387 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 388 |
client.config.set("core.sign", "change").unwrap(); |
| 389 |
client.save_config().unwrap(); |
| 390 |
write(work.path(), "a.txt", b"one\ntwo\n"); |
| 391 |
client.snapshot("t").unwrap(); |
| 392 |
let base = client.commit("base", &[]).unwrap(); |
| 393 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 394 |
client.bookmark_create("feature", Some(&[base])).unwrap(); |
| 395 |
client.bookmark_switch("feature", false).unwrap(); |
| 396 |
write(work.path(), "a.txt", b"one\ntwo\nthree\n"); |
| 397 |
client.snapshot("t").unwrap(); |
| 398 |
client.commit("add three", &[]).unwrap(); |
| 399 |
assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
| 400 |
let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await; |
| 401 |
let csrf_b = csrf_of(&preview.body); |
| 402 |
let r = post( |
| 403 |
app, |
| 404 |
"/andy/demo/proposals/new", |
| 405 |
Some(&bob), |
| 406 |
&format!("_csrf={csrf_b}&title=Add+three&body=Please&target=main&source=feature"), |
| 407 |
) |
| 408 |
.await; |
| 409 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 410 |
|
| 411 |
|
| 412 |
let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
| ed89fae5 Andy McCall ↶ |
413 |
assert!(!page.contains("Ask Claude"), "{page}"); |
| 7d2eb590 Andy McCall ↶ |
414 |
let (endpoint, seen) = mock_assistant("**Summary.** Adds a third line.\n\n- a.txt line 3: fine.").await; |
| 415 |
let admin = get(app, "/admin", Some(&andy)).await.body; |
| 416 |
let csrf_a = csrf_of(&admin); |
| 417 |
let ep = endpoint.replace(':', "%3A").replace('/', "%2F"); |
| 418 |
let r = post( |
| 419 |
app, |
| 420 |
"/admin/assistants", |
| 421 |
Some(&andy), |
| 422 |
&format!("_csrf={csrf_a}&action=add&name=Claude&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=sk-test&enabled=1"), |
| 423 |
) |
| 424 |
.await; |
| 206c1fc1 Andy McCall ↶ |
425 |
assert!(r.body.contains("Claude added and answered"), "{}", r.body); |
| 426 |
seen.lock().unwrap().clear(); |
| 7d2eb590 Andy McCall ↶ |
427 |
let r = |
| 428 |
post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf_a}&action=prompt&prompt=Review+for+correctness+first.")).await; |
| 429 |
assert!(r.body.contains("Prompt saved."), "{}", r.body); |
| 430 |
|
| 431 |
|
| 432 |
|
| 433 |
let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await.body; |
| ed89fae5 Andy McCall ↶ |
434 |
assert!(page.contains("Draft with Claude"), "{page}"); |
| 7d2eb590 Andy McCall ↶ |
435 |
let r = post( |
| 436 |
app, |
| 437 |
"/andy/demo/proposals/new/draft", |
| 438 |
Some(&bob), |
| 439 |
&format!("_csrf={}&title=Add+three&body=&target=main&source=feature&assistant=Claude", csrf_of(&page)), |
| 440 |
) |
| 441 |
.await; |
| 442 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 443 |
assert!(r.body.contains("rows=\"8\">**Summary.** Adds a third line.") && r.body.contains("value=\"Add three\""), "{}", r.body); |
| 444 |
{ |
| 445 |
let calls = seen.lock().unwrap(); |
| 446 |
assert_eq!(calls.len(), 1); |
| 447 |
assert!( |
| 448 |
calls[0].contains("drafting the description") && calls[0].contains("+three") && calls[0].contains("Proposal: Add three"), |
| 449 |
"{}", |
| 450 |
calls[0] |
| 451 |
); |
| 452 |
} |
| 453 |
let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
| ed89fae5 Andy McCall ↶ |
454 |
assert!(page.contains("Draft with Claude"), "{page}"); |
| 7d2eb590 Andy McCall ↶ |
455 |
let r = |
| 456 |
post(app, "/andy/demo/proposal/1/draft", Some(&bob), &format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page))).await; |
| 457 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 458 |
assert!( |
| 459 |
r.body.contains("tl-editform open") && r.body.contains("aria-label=\"Description (Markdown)\">**Summary.** Adds a third line."), |
| 460 |
"{}", |
| 461 |
r.body |
| 462 |
); |
| 463 |
assert!(r.body.contains("nothing is saved until you update"), "{}", r.body); |
| 464 |
assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "the description is untouched"); |
| 465 |
assert_eq!(seen.lock().unwrap().len(), 2); |
| 466 |
seen.lock().unwrap().clear(); |
| 467 |
|
| 119fd17d Andy McCall ↶ |
468 |
|
| 469 |
|
| 470 |
let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
| 471 |
assert!(page.contains("data-stream=\"/andy/demo/proposal/1/draft/stream\""), "{page}"); |
| 472 |
let form = format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page)); |
| 473 |
let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form).await; |
| 474 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 475 |
assert!(r.body.contains("event: part") && r.body.contains("event: done"), "{}", r.body); |
| 476 |
assert!(r.body.contains("Adds a third line."), "{}", r.body); |
| 477 |
assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "still nothing saved"); |
| 478 |
let r = post(app, "/andy/demo/proposal/1/draft/stream", Some(&bob), &form.replace("Claude", "Nobody")).await; |
| 479 |
assert!(r.body.contains("event: error") && r.body.contains("No assistant called Nobody"), "{}", r.body); |
| 480 |
seen.lock().unwrap().clear(); |
| 481 |
|
| 7d2eb590 Andy McCall ↶ |
482 |
|
| 483 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; |
| 484 |
let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={}&verdict=approve&body=good", csrf_of(&page))).await; |
| 485 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 486 |
let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
| ed89fae5 Andy McCall ↶ |
487 |
assert!(page.contains("Ask Claude"), "{page}"); |
| 7d2eb590 Andy McCall ↶ |
488 |
let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=claude", csrf_of(&page))).await; |
| 489 |
assert!(r.body.contains("Asked Claude to review"), "{}", r.body); |
| 490 |
let mut reviews = vec![]; |
| 491 |
for _ in 0..100 { |
| 492 |
reviews = db::proposals::reviews(&app.db, 1).await.unwrap(); |
| 493 |
if reviews.iter().any(|r| r.via == "Claude") { |
| 494 |
break; |
| 495 |
} |
| 496 |
tokio::time::sleep(std::time::Duration::from_millis(100)).await; |
| 497 |
} |
| 498 |
let claude = reviews.iter().find(|r| r.via == "Claude").expect("the assistant's review arrived"); |
| 499 |
assert_eq!((claude.author.as_str(), claude.verdict.as_str()), ("bob", "comment")); |
| 500 |
assert!(claude.body.contains("Adds a third line")); |
| 501 |
{ |
| 502 |
let calls = seen.lock().unwrap(); |
| 503 |
assert_eq!(calls.len(), 1); |
| 504 |
let c = &calls[0]; |
| 505 |
assert!(c.starts_with("Bearer sk-test "), "{c}"); |
| 506 |
assert!( |
| 507 |
c.contains("You are Claude") && c.contains("Review for correctness first."), |
| 508 |
"the system prompt and the site's prompt: {c}" |
| 509 |
); |
| 510 |
assert!(c.contains("Proposal #1: Add three") && c.contains("a.txt (modified)") && c.contains("+three"), "the context: {c}"); |
| 511 |
} |
| 512 |
|
| 513 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; |
| 514 |
assert!( |
| 515 |
page.contains("<strong>bob</strong></a> <span class=\"mute\" aria-hidden=\"true\">↳</span> <span class=\"tag\"") |
| 516 |
&& page.contains("<strong>Claude</strong>"), |
| 517 |
"{page}" |
| 518 |
); |
| 519 |
assert!(page.contains("asked Claude to review") && page.contains("Adds a third line"), "{page}"); |
| 520 |
let p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap(); |
| 521 |
let loaded = oxygenhub::web::proposals::load(app, &repo, p).await.unwrap(); |
| 522 |
let v = oxygenhub::web::proposals::verdict(app, &loaded, 1).await.unwrap(); |
| 523 |
assert!(v.approvals.iter().any(|a| a.account == "andy"), "{:?}", v.approvals.iter().map(|a| &a.account).collect::<Vec<_>>()); |
| ffb67a81 Andy McCall |
524 |
|
| 525 |
assert!(page.contains("Summarise with Claude"), "{page}"); |
| 526 |
seen.lock().unwrap().clear(); |
| 527 |
let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
| 528 |
let r = |
| 529 |
act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=Claude&task=summary", csrf_of(&page))).await; |
| 530 |
assert!(r.body.contains("Asked Claude for a summary"), "{}", r.body); |
| 531 |
let mut comments = vec![]; |
| 532 |
for _ in 0..100 { |
| 533 |
comments = db::proposals::comments(&app.db, 1).await.unwrap(); |
| 534 |
if comments.iter().any(|c| c.via == "Claude") { |
| 535 |
break; |
| 536 |
} |
| 537 |
tokio::time::sleep(std::time::Duration::from_millis(100)).await; |
| 538 |
} |
| 539 |
let summary = comments.iter().find(|c| c.via == "Claude").expect("the assistant's summary arrived"); |
| 540 |
assert_eq!(summary.author, "bob"); |
| 541 |
assert!(summary.body.contains("Adds a third line")); |
| 542 |
{ |
| 543 |
let calls = seen.lock().unwrap(); |
| 544 |
assert_eq!(calls.len(), 1); |
| 545 |
let c = &calls[0]; |
| 546 |
assert!( |
| 547 |
c.contains("called Oxygen") |
| 548 |
&& c.contains("equivalent of a merge request or pull request") |
| 549 |
&& c.contains("Please summarise the proposal"), |
| 550 |
"{c}" |
| 551 |
); |
| 552 |
assert!(c.contains("Review for correctness first.") && c.contains("+three"), "the site's prompt and the context: {c}"); |
| 553 |
} |
| 554 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; |
| 555 |
assert!(page.contains("asked Claude for a summary") && page.contains("wrote this for bob"), "{page}"); |
| 7d2eb590 Andy McCall ↶ |
556 |
|
| 557 |
let r = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1/reviews", None, None, None).await; |
| 558 |
assert!(r.body.contains("\"via\":\"Claude\""), "{}", r.body); |
| ed89fae5 Andy McCall ↶ |
559 |
|
| 560 |
let admin = get(app, "/admin", Some(&andy)).await.body; |
| 561 |
let r = post( |
| 562 |
app, |
| 563 |
"/admin/assistants", |
| 564 |
Some(&andy), |
| 565 |
&format!( |
| 566 |
"_csrf={}&action=update&id={}&name=Claude+Code&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=&enabled=1", |
| 567 |
csrf_of(&admin), |
| 568 |
oxygenhub::assistants::list(&app.db, oxygenhub::levels::Scope::Site).await.unwrap()[0].id |
| 569 |
), |
| 570 |
) |
| 571 |
.await; |
| 572 |
assert!(r.body.contains("Claude Code updated."), "{}", r.body); |
| 573 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; |
| 574 |
assert!(page.contains("<strong>Claude Code</strong>") && page.contains("asked Claude Code to review"), "{page}"); |
| 575 |
assert!(!page.contains("<strong>Claude</strong>"), "{page}"); |
| 7d2eb590 Andy McCall ↶ |
576 |
|
| 577 |
post(app, "/andy/demo/proposal/1/assistant", None, "_csrf=x&assistant=Claude").await; |
| 578 |
assert_eq!(seen.lock().unwrap().len(), 1, "no call for a stranger"); |
| 579 |
let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
| 580 |
let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=HAL", csrf_of(&page))).await; |
| 581 |
assert!(r.body.contains("No assistant called HAL"), "{}", r.body); |
| 582 |
} |
| ac713df7 Andy McCall ↶ |
583 |
|
| 584 |
|
| 585 |
|
| 586 |
|
| 587 |
#[tokio::test(flavor = "multi_thread")] |
| 588 |
async fn a_proposal_landed_by_a_push_is_marked_landed() { |
| 589 |
let h = hub().await; |
| 590 |
let app = &h.app; |
| 591 |
let andy = register(app, "andy").await; |
| 592 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 593 |
app.repos.create(&repo, None).unwrap(); |
| 594 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 595 |
let work = tempfile::tempdir().unwrap(); |
| 596 |
let mut client = Repo::init(work.path()).unwrap(); |
| 597 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 598 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 599 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 600 |
client.save_config().unwrap(); |
| 601 |
write(work.path(), "a.txt", b"one\n"); |
| 602 |
client.snapshot("t").unwrap(); |
| 603 |
let base = client.commit("base", &[]).unwrap(); |
| 604 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 605 |
client.bookmark_create("feature", Some(&[base])).unwrap(); |
| 606 |
client.bookmark_switch("feature", false).unwrap(); |
| 607 |
write(work.path(), "a.txt", b"one\ntwo\n"); |
| 608 |
client.snapshot("t").unwrap(); |
| 609 |
let feat = client.commit("add two", &[]).unwrap(); |
| 610 |
assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
| 611 |
let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; |
| 612 |
let csrf = csrf_of(&preview.body); |
| 613 |
let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await; |
| 614 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 615 |
assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state, "open"); |
| 616 |
|
| 617 |
|
| 618 |
client.bookmark_set("main", &[feat]).unwrap(); |
| 619 |
assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok)); |
| 620 |
let mut p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap(); |
| 621 |
for _ in 0..100 { |
| 622 |
if p.state == "landed" { |
| 623 |
break; |
| 624 |
} |
| 625 |
tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
| 626 |
p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap(); |
| 627 |
} |
| 628 |
assert_eq!((p.state.as_str(), p.landed_via.as_str()), ("landed", "push"), "{p:?}"); |
| 629 |
} |
| 71e65fcf Andy McCall ↶ |
630 |
|
| 631 |
|
| 632 |
|
| 633 |
#[tokio::test(flavor = "multi_thread")] |
| 634 |
async fn a_proposal_is_closed_and_reopened_through_the_api() { |
| 635 |
let h = hub().await; |
| 636 |
let app = &h.app; |
| 637 |
let andy = register(app, "andy").await; |
| 638 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 639 |
app.repos.create(&repo, None).unwrap(); |
| 640 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 641 |
let work = tempfile::tempdir().unwrap(); |
| 642 |
let mut client = Repo::init(work.path()).unwrap(); |
| 643 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 644 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 645 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 646 |
client.save_config().unwrap(); |
| 647 |
write(work.path(), "a.txt", b"one\n"); |
| 648 |
client.snapshot("t").unwrap(); |
| 649 |
let base = client.commit("base", &[]).unwrap(); |
| 650 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 651 |
client.bookmark_create("feature", Some(&[base])).unwrap(); |
| 652 |
client.bookmark_switch("feature", false).unwrap(); |
| 653 |
write(work.path(), "a.txt", b"one\ntwo\n"); |
| 654 |
client.snapshot("t").unwrap(); |
| 655 |
client.commit("add two", &[]).unwrap(); |
| 656 |
assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
| 657 |
let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; |
| 658 |
let csrf = csrf_of(&preview.body); |
| 659 |
let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await; |
| 660 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 661 |
|
| 662 |
let patch = |json: &'static str, bearer: Option<&'static str>| async move { |
| 663 |
let mut req = |
| 664 |
Request::builder().method("PATCH").uri("/api/v1/repos/andy/demo/proposals/1").header(header::CONTENT_TYPE, "application/json"); |
| 665 |
if let Some(b) = bearer { |
| 666 |
req = req.header(header::AUTHORIZATION, format!("Bearer {b}")); |
| 667 |
} |
| 668 |
let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(json)).unwrap()).await.unwrap(); |
| 669 |
let st = res.status(); |
| 670 |
(st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
| 671 |
}; |
| 672 |
let state = || async { db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state }; |
| 673 |
|
| 674 |
let (st, body) = patch(r#"{"state":"closed"}"#, Some("site")).await; |
| 675 |
assert_eq!(st, StatusCode::OK, "{body}"); |
| 676 |
assert_eq!(state().await, "closed"); |
| 677 |
assert_eq!(patch(r#"{"state":"closed"}"#, Some("site")).await.0, StatusCode::OK, "closing a closed proposal changes nothing"); |
| 678 |
let (st, _) = patch(r#"{"state":"open"}"#, Some("site")).await; |
| 679 |
assert_eq!(st, StatusCode::OK); |
| 680 |
assert_eq!(state().await, "open"); |
| 681 |
assert_eq!(patch(r#"{"state":"landed"}"#, Some("site")).await.0, StatusCode::BAD_REQUEST, "landing has its own endpoint"); |
| 682 |
assert_ne!(patch(r#"{"state":"closed"}"#, None).await.0, StatusCode::OK, "not without a token"); |
| 683 |
assert_eq!(state().await, "open"); |
| 09ffa7b6 Andy McCall ↶ |
684 |
|
| 685 |
|
| 686 |
assert_eq!(patch(r#"{"draft":true}"#, Some("site")).await.0, StatusCode::OK); |
| 687 |
assert_eq!(state().await, "draft"); |
| 688 |
let (st, body) = patch(r#"{"draft":false}"#, Some("site")).await; |
| 689 |
assert_eq!(st, StatusCode::OK, "{body}"); |
| 690 |
assert_eq!(state().await, "open", "a draft made ready"); |
| 691 |
assert_eq!(patch(r#"{"state":"draft"}"#, Some("site")).await.0, StatusCode::OK); |
| 692 |
assert_eq!(state().await, "draft"); |
| 693 |
assert_eq!(patch(r#"{"state":"open"}"#, Some("site")).await.0, StatusCode::OK); |
| 694 |
assert_eq!(state().await, "open"); |
| 695 |
assert_eq!(patch(r#"{"state":"open","draft":true}"#, Some("site")).await.0, StatusCode::BAD_REQUEST, "they disagree"); |
| 696 |
patch(r#"{"state":"closed"}"#, Some("site")).await; |
| 697 |
assert_eq!(patch(r#"{"draft":true}"#, Some("site")).await.0, StatusCode::CONFLICT, "a closed proposal is reopened, not drafted"); |
| 71e65fcf Andy McCall ↶ |
698 |
} |
| acc85ac0 Andy McCall ↶ |
699 |
|
| 2a8b6216 Andy McCall ↶ |
700 |
|
| 701 |
|
| 702 |
|
| 703 |
#[tokio::test(flavor = "multi_thread")] |
| 704 |
async fn a_maintainer_lands_over_checks_with_a_reason() { |
| 705 |
let h = hub().await; |
| 706 |
let app = &h.app; |
| 707 |
let andy = register(app, "andy").await; |
| 708 |
let bob = register(app, "bob").await; |
| 709 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 710 |
app.repos.create(&repo, None).unwrap(); |
| 711 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 712 |
let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
| 713 |
let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id; |
| 714 |
db::accounts::set_collaborator(&app.db, row.id, Some(bob_id), None, "write").await.unwrap(); |
| 715 |
let work = tempfile::tempdir().unwrap(); |
| 716 |
let mut client = Repo::init(work.path()).unwrap(); |
| 717 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 718 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 719 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 720 |
client.save_config().unwrap(); |
| 721 |
write(work.path(), "a.txt", b"one\n"); |
| 722 |
client.snapshot("t").unwrap(); |
| 723 |
let base = client.commit("base", &[]).unwrap(); |
| 724 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 725 |
client.bookmark_create("feature", Some(&[base])).unwrap(); |
| 726 |
client.bookmark_switch("feature", false).unwrap(); |
| 727 |
write(work.path(), "b.txt", b"b\n"); |
| 728 |
client.snapshot("t").unwrap(); |
| 729 |
client.commit("the fix", &[]).unwrap(); |
| 730 |
assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
| 731 |
let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; |
| 732 |
let r = post( |
| 733 |
app, |
| 734 |
"/andy/demo/proposals/new", |
| 735 |
Some(&andy), |
| 736 |
&format!("_csrf={}&title=Fix&body=&target=main&source=feature", csrf_of(&preview.body)), |
| 737 |
) |
| 738 |
.await; |
| 739 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 740 |
db::hooks::set_required_checks(&app.db, row.id, &["flow/test".to_string()]).await.unwrap(); |
| 741 |
|
| 742 |
|
| 743 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
| 744 |
assert!(page.body.contains("required checks not passed") && page.body.contains("Land now, over the checks"), "{}", page.body); |
| 745 |
let csrf = csrf_of(&page.body); |
| 746 |
let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}")).await; |
| 747 |
assert!(r.body.contains("Cannot land: required checks not passed"), "plain land is refused: {}", r.body); |
| 748 |
assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); |
| 749 |
|
| 750 |
let bob_page = get(app, "/andy/demo/proposal/1", Some(&bob)).await; |
| 751 |
assert!(!bob_page.body.contains("Land now, over the checks")); |
| 752 |
let r = post(app, "/andy/demo/proposal/1/land", Some(&bob), &format!("_csrf={}&over_checks=please", csrf_of(&bob_page.body))).await; |
| 753 |
assert_eq!(r.status, StatusCode::NOT_FOUND, "a writer cannot land over checks"); |
| 754 |
assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); |
| 755 |
|
| 756 |
let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}&over_checks=+")).await; |
| 757 |
assert!(r.body.contains("needs a reason"), "{}", r.body); |
| 758 |
assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open"); |
| 759 |
|
| 760 |
let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf}&over_checks=the+runners+are+down")).await; |
| 761 |
assert!(r.body.contains("over checks: the runners are down"), "the landing says so: {}", r.body); |
| 762 |
let p = db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap(); |
| 763 |
assert_eq!(p.state, "landed"); |
| 764 |
assert!(p.landed_via.contains("over checks: the runners are down"), "{}", p.landed_via); |
| 765 |
let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); |
| 766 |
assert!(audit |
| 767 |
.iter() |
| 768 |
.any(|a| a.action == "proposal.land_over_checks" && a.detail.contains("the runners are down") && a.detail.contains("flow/test"))); |
| 769 |
} |
| 770 |
|
| 2abea308 Andy McCall ↶ |
771 |
|
| 772 |
|
| 773 |
|
| 774 |
#[tokio::test(flavor = "multi_thread")] |
| 775 |
async fn landing_cancels_the_proposals_unstarted_runs() { |
| 776 |
let h = hub().await; |
| 777 |
let app = &h.app; |
| 778 |
let andy = register(app, "andy").await; |
| 779 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 780 |
app.repos.create(&repo, None).unwrap(); |
| 781 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 782 |
let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
| 783 |
let work = tempfile::tempdir().unwrap(); |
| 784 |
let mut client = Repo::init(work.path()).unwrap(); |
| 785 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 786 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 787 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 788 |
client.save_config().unwrap(); |
| 789 |
write(work.path(), "a.txt", b"one\n"); |
| 790 |
client.snapshot("t").unwrap(); |
| 791 |
let base = client.commit("base", &[]).unwrap(); |
| 792 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 793 |
client.bookmark_create("feature", Some(&[base])).unwrap(); |
| 794 |
client.bookmark_switch("feature", false).unwrap(); |
| 795 |
write(work.path(), "b.txt", b"b\n"); |
| 796 |
client.snapshot("t").unwrap(); |
| 797 |
client.commit("the fix", &[]).unwrap(); |
| 798 |
assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
| 799 |
let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; |
| 800 |
let r = post( |
| 801 |
app, |
| 802 |
"/andy/demo/proposals/new", |
| 803 |
Some(&andy), |
| 804 |
&format!("_csrf={}&title=Fix&body=&target=main&source=feature", csrf_of(&preview.body)), |
| 805 |
) |
| 806 |
.await; |
| 807 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 808 |
|
| 809 |
|
| 810 |
|
| 811 |
let run = |bookmark: &'static str, trigger: &'static str| { |
| 812 |
let app = app.clone(); |
| 813 |
let repo_id = row.id; |
| 814 |
async move { |
| 815 |
let job = db::flow::NewJob { |
| 816 |
name: "test".into(), |
| 817 |
depth: 0, |
| 818 |
depends: vec![], |
| 819 |
spec: "{}".into(), |
| 820 |
sandbox: "{}".into(), |
| 821 |
runtime: "process".into(), |
| 822 |
need_disk_mib: None, |
| 5588bb36 Andy McCall ↶ |
823 |
reused: None, |
| 2abea308 Andy McCall ↶ |
824 |
}; |
| 825 |
let new = |
| 826 |
db::flow::NewRun { repo_id, bookmark, state: "s", heads: "h", pipeline_hash: "", actor: "andy", trigger, error: None }; |
| 827 |
db::flow::create_run(&app.db, new, &[job]).await.unwrap() |
| 828 |
} |
| 829 |
}; |
| 830 |
let queued = run("feature", "push").await; |
| 831 |
let running = run("feature", "push").await; |
| 832 |
let own = run("proposal/1", "proposal").await; |
| 833 |
let other = run("other", "push").await; |
| 834 |
sqlx::query("UPDATE flow_runs SET status = 'running' WHERE id = $1").bind(running).execute(&app.db).await.unwrap(); |
| 835 |
sqlx::query("UPDATE flow_jobs SET status = 'running' WHERE run_id = $1").bind(running).execute(&app.db).await.unwrap(); |
| 836 |
|
| 837 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
| 838 |
let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={}", csrf_of(&page.body))).await; |
| 839 |
assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "landed", "{}", r.body); |
| 840 |
|
| 841 |
let status = |id: i64| { |
| 842 |
let app = app.clone(); |
| 843 |
async move { |
| 844 |
let run = db::flow::run_by_id(&app.db, id).await.unwrap().unwrap(); |
| 845 |
let jobs = db::flow::jobs(&app.db, id).await.unwrap(); |
| 846 |
(run.status, jobs[0].error.clone()) |
| 847 |
} |
| 848 |
}; |
| 849 |
for id in [queued, own] { |
| 850 |
let (st, why) = status(id).await; |
| 851 |
assert_eq!(st, "cancelled"); |
| 852 |
assert_eq!(why, "moot: proposal #1 landed"); |
| 853 |
} |
| 854 |
assert_eq!(status(running).await.0, "running", "a started run finishes"); |
| 855 |
assert_eq!(status(other).await.0, "queued", "another bookmark's run is untouched"); |
| 856 |
} |
| 857 |
|
| 86a69b53 Andy McCall ↶ |
858 |
|
| 859 |
|
| 860 |
|
| 861 |
#[tokio::test(flavor = "multi_thread")] |
| 862 |
async fn a_proposal_lands_itself_when_its_checks_pass() { |
| 863 |
let h = hub().await; |
| 864 |
let app = &h.app; |
| 865 |
let andy = register(app, "andy").await; |
| 866 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 867 |
app.repos.create(&repo, None).unwrap(); |
| 868 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 869 |
let row = &db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
| 870 |
let work = tempfile::tempdir().unwrap(); |
| 871 |
let mut client = Repo::init(work.path()).unwrap(); |
| 872 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 873 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 874 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 875 |
client.save_config().unwrap(); |
| 876 |
write(work.path(), "a.txt", b"one\n"); |
| 877 |
client.snapshot("t").unwrap(); |
| 878 |
let base = client.commit("base", &[]).unwrap(); |
| 879 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 880 |
for (bm, file) in [("one", "b.txt"), ("two", "c.txt")] { |
| 881 |
client.bookmark_create(bm, Some(&[base])).unwrap(); |
| 882 |
client.bookmark_switch(bm, false).unwrap(); |
| 883 |
write(work.path(), file, b"x\n"); |
| 884 |
client.snapshot("t").unwrap(); |
| 885 |
client.commit(&format!("on {bm}"), &[]).unwrap(); |
| 886 |
assert!(client.push_bookmark("origin", bm, false).unwrap().iter().all(|r| r.ok)); |
| 887 |
let preview = get(app, &format!("/andy/demo/proposals/new?source={bm}&target=main"), Some(&andy)).await; |
| 888 |
let r = post( |
| 889 |
app, |
| 890 |
"/andy/demo/proposals/new", |
| 891 |
Some(&andy), |
| 892 |
&format!("_csrf={}&title={bm}&body=&target=main&source={bm}", csrf_of(&preview.body)), |
| 893 |
) |
| 894 |
.await; |
| 895 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 896 |
} |
| 897 |
db::hooks::set_required_checks(&app.db, row.id, &["flow/test".to_string()]).await.unwrap(); |
| 898 |
let check = |number: i64, status: &'static str| async move { |
| 899 |
let p = db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap(); |
| 900 |
let report = |
| 901 |
db::hooks::CheckReport { state: &p.projected_state, name: "flow/test", status, summary: "", url: "", reporter: None, seal: "" }; |
| 902 |
db::hooks::upsert_check(&app.db, row.id, report).await.unwrap(); |
| 903 |
oxygenhub::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": { "state": p.projected_state } })).await; |
| 904 |
}; |
| 905 |
let state_of = |number: i64| async move { |
| 906 |
for _ in 0..100 { |
| 907 |
let p = db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap(); |
| 908 |
if p.state != "open" || p.auto_land_by.is_empty() { |
| 909 |
return p; |
| 910 |
} |
| 911 |
tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
| 912 |
} |
| 913 |
db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap() |
| 914 |
}; |
| 915 |
|
| 916 |
|
| 917 |
let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
| 918 |
assert!(page.body.contains("LAND WHEN CHECKS PASS"), "{}", page.body); |
| 919 |
let r = act(app, "/andy/demo/proposal/1/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; |
| 920 |
assert!(r.body.contains("lands when its checks pass (set by"), "{}", r.body); |
| 921 |
assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open", "the check has not passed"); |
| 922 |
check(1, "running").await; |
| 923 |
tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
| 924 |
assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open", "still running"); |
| 925 |
check(1, "passed").await; |
| 926 |
let p = state_of(1).await; |
| 927 |
assert_eq!((p.state.as_str(), p.landed_by.as_str()), ("landed", "andy")); |
| 928 |
assert!(p.landed_via.contains("when its checks passed (set by andy)"), "{}", p.landed_via); |
| 929 |
|
| 930 |
|
| 931 |
let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; |
| 932 |
act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; |
| 933 |
check(2, "failed").await; |
| 934 |
let p = state_of(2).await; |
| 935 |
assert_eq!((p.state.as_str(), p.auto_land_by.as_str()), ("open", "")); |
| 936 |
let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; |
| 937 |
assert!(page.body.contains("turned off landing when its checks pass") && page.body.contains("flow/test did not pass"), "{}", page.body); |
| 938 |
|
| 939 |
act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; |
| 940 |
let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; |
| 941 |
let r = act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=0", csrf_of(&page.body))).await; |
| 942 |
assert!(r.body.contains("will not land by itself"), "{}", r.body); |
| 943 |
assert!(db::proposals::get(&app.db, row.id, 2).await.unwrap().unwrap().auto_land_by.is_empty()); |
| 944 |
} |
| 945 |
|
| acc85ac0 Andy McCall ↶ |
946 |
|
| 947 |
|
| 948 |
|
| 949 |
#[tokio::test(flavor = "multi_thread")] |
| 950 |
async fn closes_in_a_landed_change_closes_the_issue() { |
| 951 |
let h = hub().await; |
| 952 |
let app = &h.app; |
| 953 |
let andy = register(app, "andy").await; |
| 954 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 955 |
app.repos.create(&repo, None).unwrap(); |
| 956 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 957 |
let json = |method: &'static str, path: String, body: &'static str| async move { |
| 958 |
let res = oxygenhub::router(app.clone()) |
| 959 |
.oneshot( |
| 960 |
Request::builder() |
| 961 |
.method(method) |
| 962 |
.uri(path) |
| 963 |
.header(header::AUTHORIZATION, "Bearer site") |
| 964 |
.header(header::CONTENT_TYPE, "application/json") |
| 965 |
.body(Body::from(body)) |
| 966 |
.unwrap(), |
| 967 |
) |
| 968 |
.await |
| 969 |
.unwrap(); |
| 970 |
let st = res.status(); |
| 971 |
(st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
| 972 |
}; |
| 973 |
for t in ["one", "two"] { |
| 974 |
let body: &'static str = if t == "one" { r#"{"title":"one"}"# } else { r#"{"title":"two"}"# }; |
| 975 |
let (st, b) = json("POST", "/api/v1/repos/andy/demo/issues".into(), body).await; |
| 976 |
assert_eq!(st, StatusCode::CREATED, "{b}"); |
| 977 |
} |
| 978 |
let work = tempfile::tempdir().unwrap(); |
| 979 |
let mut client = Repo::init(work.path()).unwrap(); |
| 980 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 981 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 982 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 983 |
client.save_config().unwrap(); |
| 984 |
write(work.path(), "a.txt", b"one\n"); |
| 985 |
client.snapshot("t").unwrap(); |
| 986 |
let base = client.commit("base", &[]).unwrap(); |
| 987 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 988 |
client.bookmark_create("feature", Some(&[base])).unwrap(); |
| 989 |
client.bookmark_switch("feature", false).unwrap(); |
| 990 |
write(work.path(), "b.txt", b"b\n"); |
| 991 |
client.snapshot("t").unwrap(); |
| 992 |
client.commit("add b\n\nCloses #1", &[]).unwrap(); |
| 993 |
assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
| 994 |
let state = |n: i64| async move { db::issues::get(&app.db, 1, n).await.unwrap().unwrap().state }; |
| 995 |
assert_eq!(state(1).await, "open", "a push to another bookmark closes nothing"); |
| 996 |
|
| 997 |
|
| 998 |
let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await; |
| 999 |
let csrf = csrf_of(&preview.body); |
| 1000 |
let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=B&body=&target=main&source=feature")).await; |
| 1001 |
assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
| 1002 |
let number = db::proposals::list(&app.db, 1, &["open"]).await.unwrap()[0].number; |
| 1003 |
let (st, b) = json("POST", format!("/api/v1/repos/andy/demo/proposals/{number}/land"), "{}").await; |
| 1004 |
assert_eq!(st, StatusCode::OK, "{b}"); |
| 1005 |
assert_eq!(state(1).await, "closed", "landing closes what the change says"); |
| 1006 |
|
| 1007 |
|
| 1008 |
client.bookmark_create("other", Some(&[base])).unwrap(); |
| 1009 |
client.bookmark_switch("other", false).unwrap(); |
| 1010 |
write(work.path(), "c.txt", b"c\n"); |
| 1011 |
client.snapshot("t").unwrap(); |
| 1012 |
let c = client.commit("add c, closes #2", &[]).unwrap(); |
| 1013 |
assert!(client.push_bookmark("origin", "other", false).unwrap().iter().all(|r| r.ok)); |
| 1014 |
assert_eq!(state(2).await, "open"); |
| 1015 |
let (_, list) = json("GET", "/api/v1/repos/andy/demo/bookmarks".into(), "").await; |
| 1016 |
let list: Vec<serde_json::Value> = serde_json::from_str(&list).unwrap(); |
| 1017 |
let main = list.iter().find(|b| b["name"] == "main").unwrap(); |
| 1018 |
let mut heads: Vec<oxy_core::hash::ChangeHash> = |
| 1019 |
main["heads"].as_array().unwrap().iter().map(|h| oxy_core::hash::ChangeHash::from_hex(h.as_str().unwrap()).unwrap()).collect(); |
| 1020 |
client.fetch("origin").unwrap(); |
| 1021 |
heads.push(c); |
| 1022 |
client.bookmark_set("main", &heads).unwrap(); |
| 1023 |
assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok)); |
| 1024 |
let mut s2 = state(2).await; |
| 1025 |
for _ in 0..100 { |
| 1026 |
if s2 == "closed" { |
| 1027 |
break; |
| 1028 |
} |
| 1029 |
tokio::time::sleep(std::time::Duration::from_millis(30)).await; |
| 1030 |
s2 = state(2).await; |
| 1031 |
} |
| 1032 |
assert_eq!(s2, "closed", "a change arriving by push closes it, though the hub held it already"); |
| 1033 |
} |
| c08cec37 Andy McCall ↶ |
1034 |
|
| 1035 |
|
| 1036 |
|
| 1037 |
async fn mock_limited(limit: usize) -> (String, std::sync::Arc<std::sync::Mutex<Vec<usize>>>) { |
| 1038 |
use std::sync::{Arc, Mutex}; |
| 1039 |
let seen: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(vec![])); |
| 1040 |
let s2 = seen.clone(); |
| 1041 |
let router = axum::Router::new().route( |
| 1042 |
"/v1/chat/completions", |
| 1043 |
axum::routing::post(move |body: String| { |
| 1044 |
let s2 = s2.clone(); |
| 1045 |
async move { |
| 1046 |
s2.lock().unwrap().push(body.len()); |
| 1047 |
if body.len() > limit { |
| 1048 |
let msg = format!("This model's maximum context length is {limit} tokens. Please reduce the length of the messages."); |
| 1049 |
return (StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": { "message": msg } }))); |
| 1050 |
} |
| 1051 |
( |
| 1052 |
StatusCode::OK, |
| 1053 |
axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": "Small draft." } }] })), |
| 1054 |
) |
| 1055 |
} |
| 1056 |
}), |
| 1057 |
); |
| 1058 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 1059 |
let port = listener.local_addr().unwrap().port(); |
| 1060 |
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
| 1061 |
(format!("http://127.0.0.1:{port}/v1/chat/completions"), seen) |
| 1062 |
} |
| 1063 |
|
| 1064 |
|
| 1065 |
|
| 1066 |
#[tokio::test(flavor = "multi_thread")] |
| 1067 |
async fn a_proposal_too_large_for_the_model_is_asked_again_smaller() { |
| 1068 |
let h = hub().await; |
| 1069 |
let app = &h.app; |
| 1070 |
let andy = register(app, "andy").await; |
| 1071 |
let repo = RepoName::parse("andy/demo").unwrap(); |
| 1072 |
app.repos.create(&repo, None).unwrap(); |
| 1073 |
db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
| 1074 |
let work = tempfile::tempdir().unwrap(); |
| 1075 |
let mut client = Repo::init(work.path()).unwrap(); |
| 1076 |
client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
| 1077 |
client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
| 1078 |
client.config.set("remote.origin.token", "site").unwrap(); |
| 1079 |
client.save_config().unwrap(); |
| 1080 |
write(work.path(), "a.txt", b"one\n"); |
| 1081 |
client.snapshot("t").unwrap(); |
| 1082 |
let base = client.commit("base", &[]).unwrap(); |
| 1083 |
assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
| 1084 |
client.bookmark_create("feature", Some(&[base])).unwrap(); |
| 1085 |
client.bookmark_switch("feature", false).unwrap(); |
| 1086 |
|
| 1087 |
let big: String = (0..200).map(|i| format!("{i:04} {}\n", "x".repeat(55))).collect(); |
| 1088 |
write(work.path(), "big.txt", big.as_bytes()); |
| 1089 |
client.snapshot("t").unwrap(); |
| 1090 |
client.commit("a big file", &[]).unwrap(); |
| 1091 |
assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); |
| 1092 |
|
| 1093 |
let (endpoint, seen) = mock_limited(6_000).await; |
| 1094 |
let admin = get(app, "/admin", Some(&andy)).await.body; |
| 1095 |
let ep = endpoint.replace(':', "%3A").replace('/', "%2F"); |
| 1096 |
let r = post( |
| 1097 |
app, |
| 1098 |
"/admin/assistants", |
| 1099 |
Some(&andy), |
| 1100 |
&format!( |
| 1101 |
"_csrf={}&action=add&name=Small&endpoint={ep}&api=chat-completions&model=small-1&token=sk-test&enabled=1&budget=12000", |
| 1102 |
csrf_of(&admin) |
| 1103 |
), |
| 1104 |
) |
| 1105 |
.await; |
| 1106 |
assert!(r.body.contains("Small added and answered"), "{}", r.body); |
| 1107 |
seen.lock().unwrap().clear(); |
| 1108 |
let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await.body; |
| 1109 |
let r = post( |
| 1110 |
app, |
| 1111 |
"/andy/demo/proposals/new/draft", |
| 1112 |
Some(&andy), |
| 1113 |
&format!("_csrf={}&title=Big&body=&target=main&source=feature&assistant=Small", csrf_of(&page)), |
| 1114 |
) |
| 1115 |
.await; |
| 1116 |
assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
| 1117 |
assert!(r.body.contains("Small draft."), "{}", r.body); |
| 1118 |
let calls = seen.lock().unwrap().clone(); |
| 1119 |
assert_eq!(calls.len(), 2, "refused once, then asked smaller: {calls:?}"); |
| 1120 |
assert!(calls[0] > 6_000 && calls[1] <= 6_000, "{calls:?}"); |
| 1121 |
} |