Renaming an assistant renames it in its reviews and events; settings pages return to the section saved ed89fae5180b
A renamed assistant keeps its name on the reviews it answered and the events that mention it, for the repositories its level covers. After a settings form posts, the page comes back scrolled to that section with the notice beside it. Tests follow the ask and draft buttons' wording.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 18:26 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
ed89fae5180b4403cf5b4b7afe0f72fb86ee8fd56170c309533442cf62ab11a1- Depends on
- 4f37afcc3583 7d2eb590051f d23464c0ac60 de505df5cf8d e71846aa07b9
- Depended on by
- 5d90e0e045e2 ffb67a81188c 155cab142b63
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 9 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified assets/hub.js +21 −0
| … | 422 unchanged lines |
||
| 423 | 423 | document.querySelectorAll('.menu-actions a[href^="#"]').forEach(function (a) { |
|
| 424 | 424 | a.addEventListener("click", function () { var d = a.closest("details"); if (d) d.open = false; }); |
|
| 425 | 425 | }); |
|
| 426 | + | ||
| 427 | + | /* Settings pages: a form posts and the page comes back from the top. |
|
| 428 | + | Remember which section's form was sent, and on the same page's next |
|
| 429 | + | load scroll back to it, bringing the notice along. */ |
|
| 430 | + | document.addEventListener("submit", function (e) { |
|
| 431 | + | var sec = e.target.closest ? e.target.closest("section[id], form[id], details[id], div[id]") : null; |
|
| 432 | + | if (!sec || !sec.id) return; |
|
| 433 | + | try { sessionStorage.setItem("oxy-return", location.pathname + "#" + sec.id); } catch (err) { /* private mode */ } |
|
| 434 | + | }); |
|
| 435 | + | try { |
|
| 436 | + | var back = sessionStorage.getItem("oxy-return"); |
|
| 437 | + | if (back && back.indexOf(location.pathname + "#") === 0) { |
|
| 438 | + | sessionStorage.removeItem("oxy-return"); |
|
| 439 | + | var target = document.getElementById(back.split("#")[1]); |
|
| 440 | + | if (target) { |
|
| 441 | + | var flash = document.querySelector("main > .note, main > .error, .sections > .note, .sections > .error"); |
|
| 442 | + | if (flash && !target.contains(flash)) target.parentNode.insertBefore(flash, target); |
|
| 443 | + | target.scrollIntoView({ block: "start" }); |
|
| 444 | + | } |
|
| 445 | + | } |
|
| 446 | + | } catch (err) { /* no storage: nothing to restore */ } |
|
| 426 | 447 | })(); |
modified crates/hub/src/assistants.rs +54 −0
| … | 257 unchanged lines |
||
| 258 | 258 | Ok(r.rows_affected() == 1) |
|
| 259 | 259 | } |
|
| 260 | 260 | ||
| 261 | + | /// A renamed assistant keeps its name on the reviews it answered and the |
|
| 262 | + | /// events that mention it, for the repositories its level covers: the |
|
| 263 | + | /// name is what people read, not a key. |
|
| 264 | + | pub async fn rename_in_history(pool: &Pool, scope: Scope, owner_hint: &str, old: &str, new: &str) -> sqlx::Result<()> { |
|
| 265 | + | if old == new || old.is_empty() || new.is_empty() { |
|
| 266 | + | return Ok(()); |
|
| 267 | + | } |
|
| 268 | + | let like = format!("%{old}%"); |
|
| 269 | + | match scope { |
|
| 270 | + | Scope::Site => { |
|
| 271 | + | sqlx::query("UPDATE proposal_reviews SET via = ? WHERE via = ?").bind(new).bind(old).execute(pool).await?; |
|
| 272 | + | sqlx::query("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ?") |
|
| 273 | + | .bind(old) |
|
| 274 | + | .bind(new) |
|
| 275 | + | .bind(&like) |
|
| 276 | + | .execute(pool) |
|
| 277 | + | .await?; |
|
| 278 | + | } |
|
| 279 | + | Scope::Org(_) => { |
|
| 280 | + | let in_org = "proposal_id IN (SELECT p.id FROM proposals p JOIN repositories r ON r.id = p.repo_id WHERE r.owner = ?)"; |
|
| 281 | + | sqlx::query(&format!("UPDATE proposal_reviews SET via = ? WHERE via = ? AND {in_org}")) |
|
| 282 | + | .bind(new) |
|
| 283 | + | .bind(old) |
|
| 284 | + | .bind(owner_hint) |
|
| 285 | + | .execute(pool) |
|
| 286 | + | .await?; |
|
| 287 | + | sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ? AND {in_org}")) |
|
| 288 | + | .bind(old) |
|
| 289 | + | .bind(new) |
|
| 290 | + | .bind(&like) |
|
| 291 | + | .bind(owner_hint) |
|
| 292 | + | .execute(pool) |
|
| 293 | + | .await?; |
|
| 294 | + | } |
|
| 295 | + | Scope::Repo(id) => { |
|
| 296 | + | let in_repo = "proposal_id IN (SELECT id FROM proposals WHERE repo_id = ?)"; |
|
| 297 | + | sqlx::query(&format!("UPDATE proposal_reviews SET via = ? WHERE via = ? AND {in_repo}")) |
|
| 298 | + | .bind(new) |
|
| 299 | + | .bind(old) |
|
| 300 | + | .bind(id) |
|
| 301 | + | .execute(pool) |
|
| 302 | + | .await?; |
|
| 303 | + | sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ? AND {in_repo}")) |
|
| 304 | + | .bind(old) |
|
| 305 | + | .bind(new) |
|
| 306 | + | .bind(&like) |
|
| 307 | + | .bind(id) |
|
| 308 | + | .execute(pool) |
|
| 309 | + | .await?; |
|
| 310 | + | } |
|
| 311 | + | } |
|
| 312 | + | Ok(()) |
|
| 313 | + | } |
|
| 314 | + | ||
| 261 | 315 | pub async fn set_enabled(pool: &Pool, id: i64, enabled: bool) -> sqlx::Result<()> { |
|
| 262 | 316 | sqlx::query("UPDATE assistants SET enabled = ?, updated_ms = ? WHERE id = ?") |
|
| 263 | 317 | .bind(enabled) |
|
| … | 528 unchanged lines |
modified crates/hub/src/web/assistants.rs +3 −0
| … | 144 unchanged lines |
||
| 145 | 145 | if !assistants::update(&app.db, id, &fields, sealed.as_deref()).await? { |
|
| 146 | 146 | return Ok(Err(format!("There is already an assistant called {} here.", fields.name))); |
|
| 147 | 147 | } |
|
| 148 | + | if a.name != fields.name { |
|
| 149 | + | assistants::rename_in_history(&app.db, at.scope, &at.owner, &a.name, &fields.name).await?; |
|
| 150 | + | } |
|
| 148 | 151 | accounts::audit(&app.db, actor, "assistant.updated", &at.label, &a.name).await.ok(); |
|
| 149 | 152 | format!("{} updated.", fields.name) |
|
| 150 | 153 | } |
|
| … | 36 unchanged lines |
modified crates/hub/tests/proposals.rs +21 −4
| … | 373 unchanged lines |
||
| 374 | 374 | ||
| 375 | 375 | // Nothing to ask until the site has an assistant. |
|
| 376 | 376 | let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
|
| 377 | - | assert!(!page.contains("ask Claude"), "{page}"); |
|
| 377 | + | assert!(!page.contains("Ask Claude"), "{page}"); |
|
| 378 | 378 | let (endpoint, seen) = mock_assistant("**Summary.** Adds a third line.\n\n- a.txt line 3: fine.").await; |
|
| 379 | 379 | let admin = get(app, "/admin", Some(&andy)).await.body; |
|
| 380 | 380 | let csrf_a = csrf_of(&admin); |
|
| … | 13 unchanged lines |
||
| 394 | 394 | // Claude drafts a description for a proposal not yet opened, and for |
|
| 395 | 395 | // the open one; neither is saved until the person says so. |
|
| 396 | 396 | let page = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await.body; |
|
| 397 | - | assert!(page.contains("draft with Claude"), "{page}"); |
|
| 397 | + | assert!(page.contains("Draft with Claude"), "{page}"); |
|
| 398 | 398 | let r = post( |
|
| 399 | 399 | app, |
|
| 400 | 400 | "/andy/demo/proposals/new/draft", |
|
| … | 13 unchanged lines |
||
| 414 | 414 | ); |
|
| 415 | 415 | } |
|
| 416 | 416 | let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
|
| 417 | - | assert!(page.contains("draft with Claude"), "{page}"); |
|
| 417 | + | assert!(page.contains("Draft with Claude"), "{page}"); |
|
| 418 | 418 | let r = |
|
| 419 | 419 | post(app, "/andy/demo/proposal/1/draft", Some(&bob), &format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page))).await; |
|
| 420 | 420 | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| … | 12 unchanged lines |
||
| 433 | 433 | let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={}&verdict=approve&body=good", csrf_of(&page))).await; |
|
| 434 | 434 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 435 | 435 | let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body; |
|
| 436 | - | assert!(page.contains("ask Claude"), "{page}"); |
|
| 436 | + | assert!(page.contains("Ask Claude"), "{page}"); |
|
| 437 | 437 | let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=claude", csrf_of(&page))).await; |
|
| 438 | 438 | assert!(r.body.contains("Asked Claude to review"), "{}", r.body); |
|
| 439 | 439 | let mut reviews = vec![]; |
|
| … | 33 unchanged lines |
||
| 473 | 473 | // The API lists the review via Claude and takes requests too. |
|
| 474 | 474 | let r = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1/reviews", None, None, None).await; |
|
| 475 | 475 | assert!(r.body.contains("\"via\":\"Claude\""), "{}", r.body); |
|
| 476 | + | // Renaming the assistant renames it on the review and in the events. |
|
| 477 | + | let admin = get(app, "/admin", Some(&andy)).await.body; |
|
| 478 | + | let r = post( |
|
| 479 | + | app, |
|
| 480 | + | "/admin/assistants", |
|
| 481 | + | Some(&andy), |
|
| 482 | + | &format!( |
|
| 483 | + | "_csrf={}&action=update&id={}&name=Claude+Code&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=&enabled=1", |
|
| 484 | + | csrf_of(&admin), |
|
| 485 | + | oxygenhub::assistants::list(&app.db, oxygenhub::levels::Scope::Site).await.unwrap()[0].id |
|
| 486 | + | ), |
|
| 487 | + | ) |
|
| 488 | + | .await; |
|
| 489 | + | assert!(r.body.contains("Claude Code updated."), "{}", r.body); |
|
| 490 | + | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body; |
|
| 491 | + | assert!(page.contains("<strong>Claude Code</strong>") && page.contains("asked Claude Code to review"), "{page}"); |
|
| 492 | + | assert!(!page.contains("<strong>Claude</strong>"), "{page}"); |
|
| 476 | 493 | // A stranger cannot ask; an unknown assistant is refused. |
|
| 477 | 494 | post(app, "/andy/demo/proposal/1/assistant", None, "_csrf=x&assistant=Claude").await; |
|
| 478 | 495 | assert_eq!(seen.lock().unwrap().len(), 1, "no call for a stranger"); |
|
| … | 4 unchanged lines |