OXYGENHUB

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