OXYGENHUB

AI assistants review proposals and draft their descriptions 7d2eb590051f

Under Reviewers, the author or a writer asks an assistant the
repository may use; the hub sends it the title, description, changes
and diff with the levels' prompts and records the answer as a comment
review via the assistant, shown with the agent badge. Approvals count
only people and an assistant's answer never replaces the asker's own
verdict. The new-proposal form and an open proposal's editor draft the
description with an assistant; nothing is saved until the person says
so. API: POST .../proposals/{n}/review-requests, via on reviews.
closes #97
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 15:47 UTC
Signature
✔ signed by Andy McCall
Hash
7d2eb590051f51c5d696dc8ff46634b4ca1ac390313a554d3efacbb8a6bab663
Depends on
145306044896 43e8ca33bc5d 4f37afcc3583 5ea4872f23e8 749d9a5fcd9f 8e96499f2705 93be9ce4653e 991b55638aaf a1b9869565aa b5668108487f b64e2537d702 b66e70b7b6c7 cc1cd197df1d d23464c0ac60 d4792b4a4629 de505df5cf8d e71846aa07b9
Depended on by
c43e4f631d99 8f0efa0729b0 33d98147511b 119fd17d213f c08cec372c81 5d90e0e045e2 b5a0a69b3622 aa0bfbd11f02 5360cea36c30 ffb67a81188c 71e65fcf95ab ac713df7b38b 62ceb2eec18c ed89fae5180b e1dc108bd814 3ea0b8af2d46 206c1fc1b06a d0fbea2eb46e d1727c573f30 4ac01a732552 2a83f5303458
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
46 atoms, 1 tree operation, 15 files

Browse the tree at this change

added crates/hub/migrations/0036_review_via.sql +3 −0
1 +
-- A review an AI assistant answered for the person who asked (hub spec
2 +
-- §13.4, #97): the assistant's name, empty for a person's own review.
3 +
ALTER TABLE proposal_reviews ADD COLUMN via TEXT NOT NULL DEFAULT '';
modified CHANGELOG.md +4 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **AI assistants, step three: drafted descriptions (#97).** The new-proposal form and an open proposal's editor have a draft-with button per assistant; the answer, written from the changes and diff, lands in the editor for the person to change before saving.
8 +
9 +
- **AI assistants, step two: review requests (#97).** Under Reviewers, the author or a writer asks an assistant the repository may use; the hub sends it the proposal's title, description, changes and diff with the site, organisation and repository prompts, and records the answer as a comment review via the assistant, shown as "bob ↳ agent Claude". Approvals count only people, and an assistant's answer never replaces the asker's own verdict. Also `POST …/proposals/{n}/review-requests` in the API, with `via` on reviews.
10 +
7 11
- **AI assistants, step one: settings (#97).** Administration, an organisation's settings and a repository's settings each hold a list of assistants (name, endpoint, API shape, model, extra headers, token, context budget, on or off) and a prompt. A repository sees its organisation's and the site's beside its own; the innermost of a name wins and one turned off above is off below. Tokens are sealed with ChaCha20-Poly1305 under `keys/assistants.key` and never shown again; TEST sends a one-line exchange. Review requests and drafted descriptions follow.
8 12
9 13
- **A runner's own labels and time limit (#98).** A runner's page under Admin → Runners has a Settings form: labels an administrator gives it beside those it reports (jobs that ask for a label match either), and a job time limit in minutes below the site's, applied when it claims.
…
91 unchanged lines
modified crates/hub-api/src/lib.rs +12 −0
…
378 unchanged lines
379 379
    pub body: String,
380 380
    pub sealed: bool,
381 381
    pub created_ms: i64,
382 +
    /// The AI assistant that answered for `author`, empty for a person's
383 +
    /// own review (hub spec §13.4).
384 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
385 +
    pub via: String,
382 386
}
383 387
384 388
// ------------------------------------------------------------- repository
…
440 unchanged lines
825 829
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
826 830
pub struct ReviewRequest {
827 831
    pub reviewers: Vec<String>,
832 +
}
833 +
834 +
/// `POST .../proposals/{number}/review-requests`: ask an AI assistant the
835 +
/// repository may use (hub spec §13.4) to review; it answers as a `comment`
836 +
/// review via the assistant.
837 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
838 +
pub struct AssistantRequest {
839 +
    pub assistant: String,
828 840
}
829 841
830 842
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
…
232 unchanged lines
modified crates/hub/src/api/mod.rs +32 −1
…
79 unchanged lines
80 80
        .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment))
81 81
        .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review))
82 82
        .route("/repos/{owner}/{name}/proposals/{number}/reviewers", post(request_reviewers))
83 +
        .route("/repos/{owner}/{name}/proposals/{number}/review-requests", post(request_assistant))
83 84
        .route("/repos/{owner}/{name}/proposals/{number}/reviewers/{username}", axum::routing::delete(withdraw_reviewer))
84 85
        .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks))
85 86
        .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files))
…
1013 unchanged lines
1099 1100
                body: r.row.body,
1100 1101
                sealed: r.sealed,
1101 1102
                created_ms: r.row.created_ms,
1103 +
                via: r.row.via,
1102 1104
            })
1103 1105
            .collect(),
1104 1106
    ))
…
21 unchanged lines
1126 1128
    crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await;
1127 1129
    Ok((
1128 1130
        StatusCode::CREATED,
1129 -
        Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms }),
1131 +
        Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms, via: r.via }),
1130 1132
    ))
1131 1133
}
1132 1134
…
994 unchanged lines
2127 2129
            .collect(),
2128 2130
    ))
2129 2131
}
2132 +
2133 +
/// `POST .../proposals/{number}/review-requests`: ask an AI assistant the
2134 +
/// repository may use to review (hub spec §13.4). Accepted at once; the
2135 +
/// answer arrives as a `comment` review via the assistant.
2136 +
async fn request_assistant(
2137 +
    State(app): State<Shared>,
2138 +
    headers: HeaderMap,
2139 +
    Path((owner, name, number)): Path<(String, String, i64)>,
2140 +
    Json(body): Json<hub_api::AssistantRequest>,
2141 +
) -> Result<(StatusCode, Json<hub_api::AssistantRequest>), ApiError> {
2142 +
    let c = caller(&app, &headers).await?;
2143 +
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
2144 +
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
2145 +
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
2146 +
    if Some(me.id) != p.author_id && role < Role::Write {
2147 +
        return Err(forbidden("only the author or a writer asks for reviews"));
2148 +
    }
2149 +
    if !p.is_open() {
2150 +
        return Err(ApiError(StatusCode::CONFLICT, "the proposal is not open".into()));
2151 +
    }
2152 +
    let a = crate::assistants::enabled_for_repo(&app.db, &row)
2153 +
        .await?
2154 +
        .into_iter()
2155 +
        .find(|a| a.name.eq_ignore_ascii_case(body.assistant.trim()))
2156 +
        .ok_or_else(|| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("no assistant called {} here", body.assistant.trim())))?;
2157 +
    let asked = hub_api::AssistantRequest { assistant: a.name.clone() };
2158 +
    crate::assistants::request_review(&app, &row, &p, &me, a).await.map_err(|e| ApiError(StatusCode::CONFLICT, e))?;
2159 +
    Ok((StatusCode::ACCEPTED, Json(asked)))
2160 +
}
modified crates/hub/src/assistants.rs +266 −0
…
522 unchanged lines
523 523
        assert_eq!(parse_headers("bad name: x").unwrap_err(), "bad name: x");
524 524
    }
525 525
}
526 +
527 +
// ------------------------------------------------------- reviewing
528 +
529 +
/// At most this many assistant calls per repository per hour.
530 +
pub const CALLS_PER_HOUR: usize = 30;
531 +
532 +
/// Who is answering what, and recent calls per repository.
533 +
#[derive(Default)]
534 +
pub struct Gate {
535 +
    busy: std::collections::HashSet<(i64, String)>,
536 +
    recent: std::collections::HashMap<i64, Vec<i64>>,
537 +
}
538 +
539 +
impl Gate {
540 +
    /// Take the slot for (proposal, assistant), or say why not.
541 +
    fn take(&mut self, repo_id: i64, proposal_id: i64, name: &str) -> Result<(), String> {
542 +
        let key = (proposal_id, name.to_lowercase());
543 +
        if self.busy.contains(&key) {
544 +
            return Err(format!("{name} is already answering this proposal"));
545 +
        }
546 +
        let now = now_ms();
547 +
        let calls = self.recent.entry(repo_id).or_default();
548 +
        calls.retain(|t| now - *t < 3_600_000);
549 +
        if calls.len() >= CALLS_PER_HOUR {
550 +
            return Err(format!("this repository has asked assistants {CALLS_PER_HOUR} times in the last hour; try later"));
551 +
        }
552 +
        calls.push(now);
553 +
        self.busy.insert(key);
554 +
        Ok(())
555 +
    }
556 +
557 +
    fn release(&mut self, proposal_id: i64, name: &str) {
558 +
        self.busy.remove(&(proposal_id, name.to_lowercase()));
559 +
    }
560 +
}
561 +
562 +
/// What a reviewer is told about a proposal: its title, description,
563 +
/// changes and diff, cut to `budget` characters.
564 +
pub fn review_context(
565 +
    row: &crate::db::repos::RepoRow,
566 +
    p: &crate::db::proposals::ProposalRow,
567 +
    loaded: &crate::web::proposals::Loaded,
568 +
    budget: usize,
569 +
) -> String {
570 +
    let mut head =
571 +
        format!("# Proposal #{}: {}\n\nRepository {}/{} · into {} · by {}\n\n", p.number, p.title, row.owner, row.name, p.target, p.author);
572 +
    if !p.body.trim().is_empty() {
573 +
        head.push_str("## Description\n\n");
574 +
        head.push_str(p.body.trim());
575 +
        head.push_str("\n\n");
576 +
    }
577 +
    context_text(head, &loaded.changes, &loaded.diffs, budget)
578 +
}
579 +
580 +
/// What a drafter is told: the title so far, the changes and the diff.
581 +
pub fn draft_context(
582 +
    row: &crate::db::repos::RepoRow,
583 +
    title: &str,
584 +
    target: &str,
585 +
    changes: &[crate::repos::view::ChangeView],
586 +
    diffs: &[oxy_core::repo::FileDiff],
587 +
    budget: usize,
588 +
) -> String {
589 +
    let head = format!(
590 +
        "# Proposal: {}\n\nRepository {}/{} · into {}\n\n",
591 +
        if title.trim().is_empty() { "(untitled)" } else { title.trim() },
592 +
        row.owner,
593 +
        row.name,
594 +
        target
595 +
    );
596 +
    context_text(head, changes, diffs, budget)
597 +
}
598 +
599 +
/// `head`, the list of changes, then the diff as `-`/`+` lines per hunk,
600 +
/// binary files noted, the diff cut so the whole fits `budget`.
601 +
fn context_text(mut text: String, changes: &[crate::repos::view::ChangeView], diffs: &[oxy_core::repo::FileDiff], budget: usize) -> String {
602 +
    text.push_str(&format!("## Changes ({})\n\n", changes.len()));
603 +
    for c in changes {
604 +
        text.push_str(&format!("- {} {}\n", c.short, c.title));
605 +
        let body = c.body.trim();
606 +
        if !body.is_empty() {
607 +
            for line in body.lines().take(20) {
608 +
                text.push_str("  ");
609 +
                text.push_str(line);
610 +
                text.push('\n');
611 +
            }
612 +
        }
613 +
    }
614 +
    text.push_str("\n## Diff\n\n");
615 +
    let head = text.chars().count();
616 +
    let mut diff = String::new();
617 +
    for f in diffs {
618 +
        diff.push_str(&format!("### {} ({})\n", f.path, f.kind));
619 +
        if let Some(from) = &f.from {
620 +
            diff.push_str(&format!("renamed from {from}\n"));
621 +
        }
622 +
        let binary = f.hunks.iter().any(|h| h.added.iter().chain(h.removed.iter().map(|(_, l)| l)).any(|l| l.contains(&0)));
623 +
        if binary {
624 +
            diff.push_str("(binary)\n\n");
625 +
            continue;
626 +
        }
627 +
        diff.push_str("```\n");
628 +
        for h in &f.hunks {
629 +
            diff.push_str(&format!("@@ after line {} @@\n", h.base_line));
630 +
            for (_, l) in &h.removed {
631 +
                diff.push('-');
632 +
                diff.push_str(String::from_utf8_lossy(l).trim_end_matches('\n'));
633 +
                diff.push('\n');
634 +
            }
635 +
            for l in &h.added {
636 +
                diff.push('+');
637 +
                diff.push_str(String::from_utf8_lossy(l).trim_end_matches('\n'));
638 +
                diff.push('\n');
639 +
            }
640 +
        }
641 +
        diff.push_str("```\n\n");
642 +
    }
643 +
    let room = budget.saturating_sub(head + 80);
644 +
    if diff.chars().count() > room {
645 +
        let cut: String = diff.chars().take(room).collect();
646 +
        text.push_str(&cut);
647 +
        text.push_str("\n\n(the diff was cut here to fit the context budget)\n");
648 +
    } else {
649 +
        text.push_str(&diff);
650 +
    }
651 +
    text
652 +
}
653 +
654 +
/// The system prompt for a review: who the assistant is, what a review
655 +
/// here is, then the prompts the levels set.
656 +
pub fn review_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
657 +
    let mut s = format!(
658 +
        "You are {name}, an AI assistant reviewing a proposal on {site}, an OxygenHub site. \
659 +
         A person asked for your review; it is recorded as a review comment made via you, under their name. \
660 +
         You cannot approve: only people approve. Answer in Markdown: start with a one-paragraph summary, \
661 +
         then findings (correctness first, then clarity), each naming the file and quoting the lines it is about; \
662 +
         keep praise out unless something is notably well done; say so plainly when you find nothing wrong.",
663 +
        name = a.name
664 +
    );
665 +
    if !prompts.trim().is_empty() {
666 +
        s.push_str("\n\n");
667 +
        s.push_str(prompts.trim());
668 +
    }
669 +
    s
670 +
}
671 +
672 +
/// Ask `a` to review proposal `p` for `by`: records the request, calls the
673 +
/// endpoint in the background, and stores the answer as a `comment` review
674 +
/// via the assistant (or an event saying why none came). `Err` is for the
675 +
/// person who asked, when nothing was started.
676 +
pub async fn request_review(
677 +
    app: &Shared,
678 +
    row: &crate::db::repos::RepoRow,
679 +
    p: &crate::db::proposals::ProposalRow,
680 +
    by: &crate::db::accounts::Account,
681 +
    a: Assistant,
682 +
) -> Result<(), String> {
683 +
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
684 +
    let pool = &app.db;
685 +
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} to review", a.name), "").await.ok();
686 +
    crate::db::accounts::audit(
687 +
        pool,
688 +
        Some(by),
689 +
        "assistant.asked",
690 +
        &format!("{}/{}", row.owner, row.name),
691 +
        &format!("{} #{}", a.name, p.number),
692 +
    )
693 +
    .await
694 +
    .ok();
695 +
    let (app, row, p, by) = (app.clone(), row.clone(), p.clone(), by.clone());
696 +
    tokio::spawn(async move {
697 +
        let outcome = answer_review(&app, &row, &p, &a).await;
698 +
        app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(p.id, &a.name);
699 +
        match outcome {
700 +
            Ok(body) => {
701 +
                if crate::db::proposals::add_review_via(&app.db, p.id, by.id, &by.username, "comment", &body, &a.name).await.is_ok() {
702 +
                    crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("review.comment via {}", a.name), "").await.ok();
703 +
                    crate::web::proposals::reviewed(&app, &row, &p, &by, "comment").await;
704 +
                    crate::hooks::emit(
705 +
                        &app,
706 +
                        &row,
707 +
                        "review",
708 +
                        &by.username,
709 +
                        serde_json::json!({ "number": p.number, "verdict": "comment", "via": a.name }),
710 +
                    )
711 +
                    .await;
712 +
                }
713 +
            }
714 +
            Err(why) => {
715 +
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
716 +
            }
717 +
        }
718 +
    });
719 +
    Ok(())
720 +
}
721 +
722 +
async fn answer_review(
723 +
    app: &Shared,
724 +
    row: &crate::db::repos::RepoRow,
725 +
    p: &crate::db::proposals::ProposalRow,
726 +
    a: &Assistant,
727 +
) -> Result<String, String> {
728 +
    let repo = crate::repos::names::RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
729 +
    let loaded = crate::web::proposals::load(app, &repo, p.clone()).await.map_err(|e| format!("the proposal could not be loaded: {e}"))?;
730 +
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
731 +
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
732 +
    let system = review_system_prompt(a, &app.config.site.name, &prompts);
733 +
    let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count()));
734 +
    ask(app, a, &system, &user).await
735 +
}
736 +
737 +
// -------------------------------------------------------- drafting
738 +
739 +
/// The system prompt for drafting a proposal's description.
740 +
pub fn draft_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
741 +
    let mut s = format!(
742 +
        "You are {name}, an AI assistant drafting the description of a proposal on {site}, an OxygenHub site, \
743 +
         for the person who will open or edit it; they read and change your draft before saving, and the draft is theirs. \
744 +
         Write Markdown for the description field only: no title line, no greeting, no sign-off. \
745 +
         Say what the changes do and why, what a reviewer should look at, and how to verify them; \
746 +
         keep to what the diff shows, and keep it short.",
747 +
        name = a.name
748 +
    );
749 +
    if !prompts.trim().is_empty() {
750 +
        s.push_str("\n\n");
751 +
        s.push_str(prompts.trim());
752 +
    }
753 +
    s
754 +
}
755 +
756 +
/// What a description is drafted from.
757 +
pub struct Draft<'a> {
758 +
    /// The proposal's id, or minus the repository's for one not yet opened:
759 +
    /// one draft per assistant at a time for it.
760 +
    pub slot: i64,
761 +
    pub title: &'a str,
762 +
    pub target: &'a str,
763 +
    pub changes: &'a [crate::repos::view::ChangeView],
764 +
    pub diffs: &'a [oxy_core::repo::FileDiff],
765 +
}
766 +
767 +
/// Ask `a` for a description, now; the answer goes into the editor,
768 +
/// nothing is saved. `Err` is for the person.
769 +
pub async fn draft_description(
770 +
    app: &Shared,
771 +
    row: &crate::db::repos::RepoRow,
772 +
    by: &crate::db::accounts::Account,
773 +
    a: &Assistant,
774 +
    d: Draft<'_>,
775 +
) -> Result<String, String> {
776 +
    let Draft { slot, title, target, changes, diffs } = d;
777 +
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, slot, &a.name)?;
778 +
    crate::db::accounts::audit(&app.db, Some(by), "assistant.drafted", &format!("{}/{}", row.owner, row.name), &a.name).await.ok();
779 +
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
780 +
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string());
781 +
    let outcome = match prompts {
782 +
        Ok(prompts) => {
783 +
            let system = draft_system_prompt(a, &app.config.site.name, &prompts);
784 +
            let user = draft_context(row, title, target, changes, diffs, budget.saturating_sub(system.chars().count()));
785 +
            ask(app, a, &system, &user).await
786 +
        }
787 +
        Err(e) => Err(e),
788 +
    };
789 +
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(slot, &a.name);
790 +
    outcome.map(|t| t.trim().to_string())
791 +
}
modified crates/hub/src/db/proposals.rs +19 −1
…
73 unchanged lines
74 74
    pub verdict: String,
75 75
    pub body: String,
76 76
    pub created_ms: i64,
77 +
    /// The AI assistant that answered for the author (migration 0036),
78 +
    /// empty for the author's own review.
79 +
    #[sqlx(default)]
80 +
    pub via: String,
77 81
}
78 82
79 83
#[derive(Clone, Debug, FromRow)]
…
178 unchanged lines
258 262
}
259 263
260 264
pub async fn add_review(pool: &Pool, proposal_id: i64, author_id: i64, author: &str, verdict: &str, body: &str) -> sqlx::Result<ReviewRow> {
265 +
    add_review_via(pool, proposal_id, author_id, author, verdict, body, "").await
266 +
}
267 +
268 +
/// A review answered by an AI assistant (`via`) for `author`, who asked.
269 +
pub async fn add_review_via(
270 +
    pool: &Pool,
271 +
    proposal_id: i64,
272 +
    author_id: i64,
273 +
    author: &str,
274 +
    verdict: &str,
275 +
    body: &str,
276 +
    via: &str,
277 +
) -> sqlx::Result<ReviewRow> {
261 278
    let row = sqlx::query_as(
262 -
        "INSERT INTO proposal_reviews (proposal_id, author_id, author, verdict, body, created_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
279 +
        "INSERT INTO proposal_reviews (proposal_id, author_id, author, verdict, body, created_ms, via) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *",
263 280
    )
264 281
    .bind(proposal_id)
265 282
    .bind(author_id)
…
1 unchanged lines
267 284
    .bind(verdict)
268 285
    .bind(body)
269 286
    .bind(now_ms())
287 +
    .bind(via)
270 288
    .fetch_one(pool)
271 289
    .await?;
272 290
    touch(pool, proposal_id).await?;
…
85 unchanged lines
modified crates/hub/src/lib.rs +4 −0
…
36 unchanged lines
37 37
    pub cookie_key: auth::CookieKey,
38 38
    /// Seals AI assistants' tokens at rest (hub spec §13.4).
39 39
    pub assistant_key: assistants::SecretKey,
40 +
    /// Which assistants are answering which proposals, and recent calls
41 +
    /// per repository (hub spec §13.4).
42 +
    pub assistant_gate: std::sync::Mutex<assistants::Gate>,
40 43
    pub events: events::Events,
41 44
    /// Who is resolving which conflict together (hub spec §6.10).
42 45
    pub rooms: Arc<live::Rooms>,
…
27 unchanged lines
70 73
        hub_key,
71 74
        cookie_key,
72 75
        assistant_key,
76 +
        assistant_gate: std::sync::Mutex::new(assistants::Gate::default()),
73 77
        events: events::Events::default(),
74 78
        rooms: Arc::new(live::Rooms::default()),
75 79
        started: std::time::Instant::now(),
…
50 unchanged lines
modified crates/hub/src/web/mod.rs +3 −0
…
151 unchanged lines
152 152
        .route("/{owner}/{name}/settings/requirements", post(repo_settings::requirements))
153 153
        .route("/{owner}/{name}/proposals", get(proposals::list))
154 154
        .route("/{owner}/{name}/proposals/new", get(proposals::new_form).post(proposals::create))
155 +
        .route("/{owner}/{name}/proposals/new/draft", post(proposals::new_draft))
155 156
        .route("/{owner}/{name}/proposal/{number}", get(proposals::show))
156 157
        .route("/{owner}/{name}/proposal/{number}/comment", post(proposals::comment))
157 158
        .route("/{owner}/{name}/proposal/{number}/comment/{id}/resolve", post(proposals::resolve_comment))
158 159
        .route("/{owner}/{name}/proposal/{number}/review", post(proposals::review))
159 160
        .route("/{owner}/{name}/proposal/{number}/reviewers", post(proposals::reviewers))
161 +
        .route("/{owner}/{name}/proposal/{number}/assistant", post(proposals::assistant))
162 +
        .route("/{owner}/{name}/proposal/{number}/draft", post(proposals::draft))
160 163
        .route("/{owner}/{name}/proposal/{number}/edit", post(proposals::edit))
161 164
        .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state))
162 165
        .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land))
…
469 unchanged lines
modified crates/hub/src/web/proposals.rs +219 −1
…
136 unchanged lines
137 137
    /// Preview: the changes the proposal would add.
138 138
    preview: Vec<ChangeView>,
139 139
    conflicted: bool,
140 +
    /// What the form holds so far (after a failed open, or a draft).
141 +
    title: String,
142 +
    body: String,
143 +
    /// AI assistants that may draft the description (hub spec §13.4).
144 +
    assistants: Vec<String>,
140 145
}
141 146
142 147
#[derive(Deserialize)]
…
69 unchanged lines
212 217
    let source = q.source.unwrap_or_default();
213 218
    let (preview, conflicted) =
214 219
        if source.is_empty() { (vec![], false) } else { preview_for(&app, &repo, &target, &source).await.unwrap_or((vec![], false)) };
220 +
    let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await };
215 221
    Ok(Page(NewProposalPage {
216 222
        shell,
217 223
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
…
3 unchanged lines
221 227
        error: None,
222 228
        preview,
223 229
        conflicted,
230 +
        title: String::new(),
231 +
        body: String::new(),
232 +
        assistants,
224 233
    }))
225 234
}
226 235
…
8 unchanged lines
235 244
    source: String,
236 245
    #[serde(default)]
237 246
    draft: Option<String>,
247 +
    /// For `/proposals/new/draft`: which assistant writes the description.
248 +
    #[serde(default)]
249 +
    assistant: String,
238 250
}
239 251
240 252
/// Open a proposal from a form or the API: validate, create, index, notify.
…
105 unchanged lines
346 358
                error: Some(msg),
347 359
                preview: vec![],
348 360
                conflicted: false,
361 +
                title: f.title.clone(),
362 +
                body: f.body.clone(),
363 +
                assistants: vec![],
349 364
            })
350 365
            .into_response())
351 366
        }
…
156 unchanged lines
508 523
    let sealed_accounts: Vec<String> = links.iter().filter(|(k, _)| loaded.sealing_keys.contains(k)).map(|(_, u)| u.clone()).collect();
509 524
    // Latest verdict per reviewer.
510 525
    let mut latest: BTreeMap<String, &pdb::ReviewRow> = BTreeMap::new();
511 -
    for r in &rows {
526 +
    for r in rows.iter().filter(|r| r.via.is_empty()) {
527 +
        // An assistant's answer is never the person's own verdict.
512 528
        latest.insert(r.author.clone(), r);
513 529
    }
514 530
    let mut approvals = Vec::new();
…
127 unchanged lines
642 658
    reviewable: Vec<String>,
643 659
    /// The viewer may ask for reviews (the author, or a writer).
644 660
    can_request: bool,
661 +
    /// AI assistants the viewer may ask, by name (hub spec §13.4).
662 +
    assistants: Vec<String>,
663 +
    /// A description an assistant just drafted, for the editor.
664 +
    draft: Option<String>,
645 665
}
646 666
647 667
#[derive(Deserialize)]
…
87 unchanged lines
735 755
            reviewable.push(r.clone());
736 756
        }
737 757
    }
758 +
    let assistants: Vec<String> = if (can_request || can_edit) && p.is_open() { assistant_names(app, &row).await } else { Vec::new() };
738 759
    let delegated = match viewer.account() {
739 760
        Some(a) => super::edit::delegated_identity(app, a.id).await.is_some(),
740 761
        None => false,
…
75 unchanged lines
816 837
        required_checks: db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(),
817 838
        requested,
818 839
        reviewable,
840 +
        assistants,
841 +
        draft: None,
819 842
        can_request,
820 843
    }))
821 844
}
…
620 unchanged lines
1442 1465
        Ok(asked) if asked.is_empty() => Ok(back(&owner, &name, number, "conversation", Some("Already asked.".into()), None)),
1443 1466
        Ok(asked) => Ok(back(&owner, &name, number, "conversation", Some(format!("Asked {} to review.", asked.join(", "))), None)),
1444 1467
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(e))),
1468 +
    }
1469 +
}
1470 +
1471 +
#[derive(Deserialize)]
1472 +
pub struct AssistantForm {
1473 +
    #[serde(rename = "_csrf")]
1474 +
    csrf_token: String,
1475 +
    assistant: String,
1476 +
}
1477 +
1478 +
/// `POST /{owner}/{name}/proposal/{number}/assistant`: the author or a
1479 +
/// writer asks an AI assistant to review (hub spec §13.4). The answer
1480 +
/// comes later as a review via the assistant.
1481 +
pub async fn assistant(
1482 +
    State(app): State<Shared>,
1483 +
    viewer: Viewer,
1484 +
    Path((owner, name, number)): Path<(String, String, i64)>,
1485 +
    Form(f): Form<AssistantForm>,
1486 +
) -> Result<Response, Error> {
1487 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1488 +
    let me = require(&viewer, &shell)?;
1489 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1490 +
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1491 +
    let p = pdb::get(&app.db, row.id, number)
1492 +
        .await
1493 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
1494 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1495 +
    if Some(me.account.id) != p.author_id && role < Role::Write {
1496 +
        return Err(Error::not_found(shell));
1497 +
    }
1498 +
    if !p.is_open() {
1499 +
        return Ok(back(&owner, &name, number, "conversation", None, Some("The proposal is not open.".into())));
1500 +
    }
1501 +
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
1502 +
        .await
1503 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
1504 +
        .into_iter()
1505 +
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1506 +
    else {
1507 +
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
1508 +
    };
1509 +
    let who = a.name.clone();
1510 +
    match crate::assistants::request_review(&app, &row, &p, &me.account, a).await {
1511 +
        Ok(()) => Ok(back(
1512 +
            &owner,
1513 +
            &name,
1514 +
            number,
1515 +
            "conversation",
1516 +
            Some(format!("Asked {who} to review; its answer appears here when it comes.")),
1517 +
            None,
1518 +
        )),
1519 +
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("Not asked: {e}.")))),
1520 +
    }
1521 +
}
1522 +
1523 +
/// The assistants a repository may use, by name.
1524 +
async fn assistant_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> {
1525 +
    crate::assistants::enabled_for_repo(&app.db, row).await.unwrap_or_default().into_iter().map(|a| a.name).collect()
1526 +
}
1527 +
1528 +
/// The changes and the file diffs a proposal from `source` into `target`
1529 +
/// would carry.
1530 +
async fn preview_diff(
1531 +
    app: &Shared,
1532 +
    repo: &RepoName,
1533 +
    target: &str,
1534 +
    source: &str,
1535 +
) -> Result<(Vec<ChangeView>, Vec<oxy_core::repo::FileDiff>), crate::repos::RepoError> {
1536 +
    let Some((fork, bookmark)) = parse_source(app, repo, source) else { return Ok((vec![], vec![])) };
1537 +
    let target = target.to_string();
1538 +
    let fork_dir = fork.as_ref().map(|f| app.repos.dir(f));
1539 +
    app.repos
1540 +
        .read(repo, move |r, cache| {
1541 +
            let projection = match &fork_dir {
1542 +
                Some(dir) => {
1543 +
                    let fork_repo = oxy_core::repo::Repo::open(dir)?;
1544 +
                    let heads = fork_repo.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
1545 +
                    pl::project(r, cache, &target, Source::Fork { fork: &fork_repo, heads })?
1546 +
                }
1547 +
                None => {
1548 +
                    let heads = r.bookmarks()?.into_iter().find(|b| b.name == bookmark).map(|b| b.heads).unwrap_or_default();
1549 +
                    pl::project(r, cache, &target, Source::Bookmark(heads))?
1550 +
                }
1551 +
            };
1552 +
            Ok((projection.changes, projection.files))
1553 +
        })
1554 +
        .await
1555 +
}
1556 +
1557 +
/// `POST /{owner}/{name}/proposals/new/draft`: an assistant drafts the
1558 +
/// description from the changes the proposal would add; the form comes
1559 +
/// back with the draft to edit (hub spec §13.4).
1560 +
pub async fn new_draft(
1561 +
    State(app): State<Shared>,
1562 +
    viewer: Viewer,
1563 +
    Path((owner, name)): Path<(String, String)>,
1564 +
    Form(f): Form<NewForm>,
1565 +
) -> Result<Page<NewProposalPage>, Error> {
1566 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposals/new"), &viewer);
1567 +
    let me = require(&viewer, &shell)?;
1568 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1569 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1570 +
    let bookmarks =
1571 +
        app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
1572 +
    let (preview, conflicted) = preview_for(&app, &repo, &f.target, &f.source).await.unwrap_or((vec![], false));
1573 +
    let assistants = if preview.is_empty() { vec![] } else { assistant_names(&app, &row).await };
1574 +
    let mut page = NewProposalPage {
1575 +
        shell,
1576 +
        repo: RepoShell::new(&app, &row, "proposals").with_role(role),
1577 +
        bookmarks,
1578 +
        source: f.source.clone(),
1579 +
        target: f.target.clone(),
1580 +
        error: None,
1581 +
        preview,
1582 +
        conflicted,
1583 +
        title: f.title.clone(),
1584 +
        body: f.body.clone(),
1585 +
        assistants,
1586 +
    };
1587 +
    let assistant = crate::assistants::enabled_for_repo(&app.db, &row)
1588 +
        .await
1589 +
        .unwrap_or_default()
1590 +
        .into_iter()
1591 +
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()));
1592 +
    let Some(a) = assistant else {
1593 +
        page.error = Some(format!("No assistant called {} here.", f.assistant.trim()));
1594 +
        return Ok(Page(page));
1595 +
    };
1596 +
    if page.preview.is_empty() {
1597 +
        page.error = Some("Nothing to describe: the source adds nothing to the target.".into());
1598 +
        return Ok(Page(page));
1599 +
    }
1600 +
    let (changes, diffs) = match preview_diff(&app, &repo, &f.target, &f.source).await {
1601 +
        Ok(x) => x,
1602 +
        Err(e) => {
1603 +
            page.error = Some(e.to_string());
1604 +
            return Ok(Page(page));
1605 +
        }
1606 +
    };
1607 +
    let d = crate::assistants::Draft { slot: -row.id, title: &f.title, target: &f.target, changes: &changes, diffs: &diffs };
1608 +
    match crate::assistants::draft_description(&app, &row, &me.account, &a, d).await {
1609 +
        Ok(text) => page.body = text,
1610 +
        Err(why) => page.error = Some(format!("{} did not answer: {why}.", a.name)),
1611 +
    }
1612 +
    Ok(Page(page))
1613 +
}
1614 +
1615 +
#[derive(Deserialize)]
1616 +
pub struct DraftForm {
1617 +
    #[serde(rename = "_csrf")]
1618 +
    csrf_token: String,
1619 +
    assistant: String,
1620 +
    #[serde(default)]
1621 +
    title: String,
1622 +
}
1623 +
1624 +
/// `POST /{owner}/{name}/proposal/{number}/draft`: an assistant drafts the
1625 +
/// description of an open proposal from its changes; the editor opens
1626 +
/// with the draft, nothing is saved (hub spec §13.4).
1627 +
pub async fn draft(
1628 +
    State(app): State<Shared>,
1629 +
    viewer: Viewer,
1630 +
    Path((owner, name, number)): Path<(String, String, i64)>,
1631 +
    Form(f): Form<DraftForm>,
1632 +
) -> Result<Response, Error> {
1633 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1634 +
    let me = require(&viewer, &shell)?;
1635 +
    csrf(&viewer, &f.csrf_token, &shell)?;
1636 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1637 +
    let p = pdb::get(&app.db, row.id, number)
1638 +
        .await
1639 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
1640 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1641 +
    if Some(me.account.id) != p.author_id && role < Role::Maintain {
1642 +
        return Err(Error::not_found(shell));
1643 +
    }
1644 +
    let Some(a) = crate::assistants::enabled_for_repo(&app.db, &row)
1645 +
        .await
1646 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
1647 +
        .into_iter()
1648 +
        .find(|a| a.name.eq_ignore_ascii_case(f.assistant.trim()))
1649 +
    else {
1650 +
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
1651 +
    };
1652 +
    let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
1653 +
    let title = if f.title.trim().is_empty() { p.title.clone() } else { f.title.clone() };
1654 +
    let d = crate::assistants::Draft { slot: p.id, title: &title, target: &p.target, changes: &loaded.changes, diffs: &loaded.diffs };
1655 +
    let draft = crate::assistants::draft_description(&app, &row, &me.account, &a, d).await;
1656 +
    match draft {
1657 +
        Ok(text) => {
1658 +
            let mut page = detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await?;
1659 +
            page.0.draft = Some(text);
1660 +
            Ok(page.into_response())
1661 +
        }
1662 +
        Err(why) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("{} did not answer: {why}.", a.name)))),
1445 1663
    }
1446 1664
}
modified crates/hub/tests/proposals.rs +169 −0
…
310 unchanged lines
311 311
    let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await;
312 312
    assert!(r.body.contains(">open<"), "{}", r.body);
313 313
}
314 +
315 +
/// A stand-in chat-completions endpoint that answers with a fixed review
316 +
/// and keeps the body of each call.
317 +
async fn mock_assistant(answer: &'static str) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
318 +
    use std::sync::{Arc, Mutex};
319 +
    let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
320 +
    let s2 = seen.clone();
321 +
    let router = axum::Router::new().route(
322 +
        "/v1/chat/completions",
323 +
        axum::routing::post(move |headers: axum::http::HeaderMap, body: String| {
324 +
            let s2 = s2.clone();
325 +
            async move {
326 +
                let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
327 +
                s2.lock().unwrap().push(format!("{auth} {body}"));
328 +
                axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": answer } }] }))
329 +
            }
330 +
        }),
331 +
    );
332 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
333 +
    let port = listener.local_addr().unwrap().port();
334 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
335 +
    (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen)
336 +
}
337 +
338 +
#[tokio::test(flavor = "multi_thread")]
339 +
async fn an_assistant_reviews_a_proposal_via_the_person_who_asked() {
340 +
    let h = hub().await;
341 +
    let app = &h.app;
342 +
    let andy = register(app, "andy").await;
343 +
    let bob = register(app, "bob").await;
344 +
    let repo = RepoName::parse("andy/demo").unwrap();
345 +
    app.repos.create(&repo, None).unwrap();
346 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
347 +
    let work = tempfile::tempdir().unwrap();
348 +
    let mut client = Repo::init(work.path()).unwrap();
349 +
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
350 +
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
351 +
    client.config.set("remote.origin.token", "site").unwrap();
352 +
    client.config.set("core.sign", "change").unwrap();
353 +
    client.save_config().unwrap();
354 +
    write(work.path(), "a.txt", b"one\ntwo\n");
355 +
    client.snapshot("t").unwrap();
356 +
    let base = client.commit("base", &[]).unwrap();
357 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
358 +
    client.bookmark_create("feature", Some(&[base])).unwrap();
359 +
    client.bookmark_switch("feature", false).unwrap();
360 +
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
361 +
    client.snapshot("t").unwrap();
362 +
    client.commit("add three", &[]).unwrap();
363 +
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
364 +
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await;
365 +
    let csrf_b = csrf_of(&preview.body);
366 +
    let r = post(
367 +
        app,
368 +
        "/andy/demo/proposals/new",
369 +
        Some(&bob),
370 +
        &format!("_csrf={csrf_b}&title=Add+three&body=Please&target=main&source=feature"),
371 +
    )
372 +
    .await;
373 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
374 +
375 +
    // Nothing to ask until the site has an assistant.
376 +
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
377 +
    assert!(!page.contains("ask Claude"), "{page}");
378 +
    let (endpoint, seen) = mock_assistant("**Summary.** Adds a third line.\n\n- a.txt line 3: fine.").await;
379 +
    let admin = get(app, "/admin", Some(&andy)).await.body;
380 +
    let csrf_a = csrf_of(&admin);
381 +
    let ep = endpoint.replace(':', "%3A").replace('/', "%2F");
382 +
    let r = post(
383 +
        app,
384 +
        "/admin/assistants",
385 +
        Some(&andy),
386 +
        &format!("_csrf={csrf_a}&action=add&name=Claude&endpoint={ep}&api=chat-completions&model=claude-opus-5-5&token=sk-test&enabled=1"),
387 +
    )
388 +
    .await;
389 +
    assert!(r.body.contains("Claude added."), "{}", r.body);
390 +
    let r =
391 +
        post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf_a}&action=prompt&prompt=Review+for+correctness+first.")).await;
392 +
    assert!(r.body.contains("Prompt saved."), "{}", r.body);
393 +
394 +
    // Claude drafts a description for a proposal not yet opened, and for
395 +
    // the open one; neither is saved until the person says so.
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}");
398 +
    let r = post(
399 +
        app,
400 +
        "/andy/demo/proposals/new/draft",
401 +
        Some(&bob),
402 +
        &format!("_csrf={}&title=Add+three&body=&target=main&source=feature&assistant=Claude", csrf_of(&page)),
403 +
    )
404 +
    .await;
405 +
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
406 +
    assert!(r.body.contains("rows=\"8\">**Summary.** Adds a third line.") && r.body.contains("value=\"Add three\""), "{}", r.body);
407 +
    {
408 +
        let calls = seen.lock().unwrap();
409 +
        assert_eq!(calls.len(), 1);
410 +
        assert!(
411 +
            calls[0].contains("drafting the description") && calls[0].contains("+three") && calls[0].contains("Proposal: Add three"),
412 +
            "{}",
413 +
            calls[0]
414 +
        );
415 +
    }
416 +
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
417 +
    assert!(page.contains("draft with Claude"), "{page}");
418 +
    let r =
419 +
        post(app, "/andy/demo/proposal/1/draft", Some(&bob), &format!("_csrf={}&assistant=Claude&title=Add+three", csrf_of(&page))).await;
420 +
    assert_eq!(r.status, StatusCode::OK, "{}", r.body);
421 +
    assert!(
422 +
        r.body.contains("tl-editform open") && r.body.contains("aria-label=\"Description (Markdown)\">**Summary.** Adds a third line."),
423 +
        "{}",
424 +
        r.body
425 +
    );
426 +
    assert!(r.body.contains("nothing is saved until you update"), "{}", r.body);
427 +
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().body, "Please", "the description is untouched");
428 +
    assert_eq!(seen.lock().unwrap().len(), 2);
429 +
    seen.lock().unwrap().clear();
430 +
431 +
    // Andy approves; then Bob, the author, asks Claude.
432 +
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
433 +
    let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={}&verdict=approve&body=good", csrf_of(&page))).await;
434 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
435 +
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
436 +
    assert!(page.contains("ask Claude"), "{page}");
437 +
    let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=claude", csrf_of(&page))).await;
438 +
    assert!(r.body.contains("Asked Claude to review"), "{}", r.body);
439 +
    let mut reviews = vec![];
440 +
    for _ in 0..100 {
441 +
        reviews = db::proposals::reviews(&app.db, 1).await.unwrap();
442 +
        if reviews.iter().any(|r| r.via == "Claude") {
443 +
            break;
444 +
        }
445 +
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
446 +
    }
447 +
    let claude = reviews.iter().find(|r| r.via == "Claude").expect("the assistant's review arrived");
448 +
    assert_eq!((claude.author.as_str(), claude.verdict.as_str()), ("bob", "comment"));
449 +
    assert!(claude.body.contains("Adds a third line"));
450 +
    {
451 +
        let calls = seen.lock().unwrap();
452 +
        assert_eq!(calls.len(), 1);
453 +
        let c = &calls[0];
454 +
        assert!(c.starts_with("Bearer sk-test "), "{c}");
455 +
        assert!(
456 +
            c.contains("You are Claude") && c.contains("Review for correctness first."),
457 +
            "the system prompt and the site's prompt: {c}"
458 +
        );
459 +
        assert!(c.contains("Proposal #1: Add three") && c.contains("a.txt (modified)") && c.contains("+three"), "the context: {c}");
460 +
    }
461 +
    // Shown as Bob via Claude, with the event; Andy's approval still counts.
462 +
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
463 +
    assert!(
464 +
        page.contains("<strong>bob</strong></a> <span class=\"mute\" aria-hidden=\"true\">↳</span> <span class=\"tag\"")
465 +
            && page.contains("<strong>Claude</strong>"),
466 +
        "{page}"
467 +
    );
468 +
    assert!(page.contains("asked Claude to review") && page.contains("Adds a third line"), "{page}");
469 +
    let p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
470 +
    let loaded = oxygenhub::web::proposals::load(app, &repo, p).await.unwrap();
471 +
    let v = oxygenhub::web::proposals::verdict(app, &loaded, 1).await.unwrap();
472 +
    assert!(v.approvals.iter().any(|a| a.account == "andy"), "{:?}", v.approvals.iter().map(|a| &a.account).collect::<Vec<_>>());
473 +
    // The API lists the review via Claude and takes requests too.
474 +
    let r = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1/reviews", None, None, None).await;
475 +
    assert!(r.body.contains("\"via\":\"Claude\""), "{}", r.body);
476 +
    // A stranger cannot ask; an unknown assistant is refused.
477 +
    post(app, "/andy/demo/proposal/1/assistant", None, "_csrf=x&assistant=Claude").await;
478 +
    assert_eq!(seen.lock().unwrap().len(), 1, "no call for a stranger");
479 +
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
480 +
    let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=HAL", csrf_of(&page))).await;
481 +
    assert!(r.body.contains("No assistant called HAL"), "{}", r.body);
482 +
}
modified docs/spec.md +6 −6
…
354 unchanged lines
355 355
356 356
### 7.4 Reviews and approvals
357 357
358 -
The author or a writer can **ask for a review** from anyone who can read the repository (not the author), under Reviewers on the proposal page or with `POST /api/v1/repos/{o}/{n}/proposals/{number}/reviewers` and `{"reviewers": [...]}`; `DELETE .../reviewers/{username}` withdraws a request. Each person asked gets a notification (`proposal.review_requested`), the timeline records it, the proposal's JSON lists `requested_reviewers`, and a review from that person answers the request. Every review notifies the proposal's author (`proposal.review`).
358 +
The author or a writer can **ask for a review** from anyone who can read the repository (not the author), under Reviewers on the proposal page or with `POST /api/v1/repos/{o}/{n}/proposals/{number}/reviewers` and `{"reviewers": [...]}`; `DELETE .../reviewers/{username}` withdraws a request. Each person asked gets a notification (`proposal.review_requested`), the timeline records it, the proposal's JSON lists `requested_reviewers`, and a review from that person answers the request. Every review notifies the proposal's author (`proposal.review`). An AI assistant the repository may use (§13.4) can be asked the same way; its answer is a `comment` review recorded via the assistant under the asker's name.
359 359
360 360
A review is `comment`, `request changes`, or `approve`. An approval MUST be backed by a **seal attestation** (spec §14.4, `kind = seal`) over the proposal's change hashes, signed with a key linked to the reviewer's account. Private keys never enter the browser, so the reviewer signs from a client: `oxy seal --proposal <url>` or `oxy seal <hashes>` (§18, a new command; the seal object already exists in the model) signs and pushes the seal; the hub matches the seal to the proposal and records the approval. The page shows the command ready to copy. An approval not yet backed by a seal is shown as "approval pending signature" and does not count towards requirements.
361 361
…
175 unchanged lines
537 537
- *Costs.* One microVM per open workspace: 2 vCPU and 4 GB by default, from the runner's capacity like any job, so open workspaces compete with pipeline jobs; a runner label `workspace` keeps them on runners meant for it. Idle stop at 30 minutes keeps the fleet small.
538 538
- *Open questions.* Whether the Oxygen VCS extension should talk to the microVM's `oxy` (simplest; works offline in the VM) or to the hub's API (no `oxy` in the VM, but a second code path for every operation): the first. Whether a stopped workspace's overlay should be kept for a day so an unpushed edit is not lost: probably yes, as a tarball artifact of the job, restored on reopen. Whether to allow terminals: yes, it is the point.
539 539
540 -
### 13.4 AI assistants (settings done 2026-10-04; review requests and drafts planned)
540 +
### 13.4 AI assistants (done 2026-10-04; settings API planned)
541 541
542 542
An **assistant** is a chat-completion endpoint the hub may ask to review a proposal or draft its description (oxygenhub#97). Assistants are configured at three levels and combined downwards:
543 543
544 544
- **Where.** Administration (site), an organisation's settings, a repository's settings each hold a list of assistants: name (`Claude`, `Antigravity`, `ChatGPT`, `DeepSeek`, …), endpoint URL, API shape (`chat-completions` or `anthropic-messages`), model, extra headers, the token, and an *enabled* switch. Each level also holds a **prompt**. A repository sees the assistants of its organisation and of the site beside its own; an assistant disabled at an outer level is off for everything inside it, whatever an inner level says. The effective prompt for a call is the site's, then the organisation's, then the repository's, each under a heading, so a project adds to what the site says rather than replacing it, and each settings page shows the prompt it inherits above its own.
545 545
- **Secrets.** Tokens are sealed at rest with ChaCha20-Poly1305 under `keys/assistants.key` (mode 0600, made at first start beside the cookie key; each token carries its own random nonce), shown only as set or none, and never returned by the API. An empty token suits endpoints that need none. Each settings panel has TEST, a one-line exchange whose answer or error is shown. Each level's changes are in the audit log (`assistant.added`, `.updated`, `.enabled`, `.disabled`, `.deleted`, `.prompt`).
546 -
- **Request review.** The proposal page's Reviewers menu lists the enabled assistants beside people. Asking one queues a worker task: the hub builds the context (the proposal's title and description, the diff of each changed file, trimmed to a size budget, the effective prompt), calls the endpoint, and records the answer as a review of verdict `comment` whose author is the person who asked and whose **via** is the assistant, shown as "andymccall via Claude" with an `agent` badge, exactly as agent-signed changes are shown (§6). An assistant never approves: approvals count only people. The event and the audit log record who asked which assistant; the proposal's author is told as for any review.
547 -
- **Draft the description.** On a new or open proposal, "Draft with …" asks an assistant for a description from the changes and puts it in the editor for the requester to edit before saving; nothing is saved by the assistant itself.
548 -
- **Limits and failure.** One request per assistant per proposal at a time, a per-repository rate limit, a 60-second call timeout, and a context budget (characters) per assistant; a failed call leaves an event ("Claude did not answer: …") and no review.
549 -
- **API.** `/repos/{o}/{n}/proposals/{n}/review-requests {"assistant": NAME}` and the settings endpoints mirror the pages. Hub actions by an assistant are attributed "ASSISTANT via WHO" everywhere, so a project can require that a human has approved regardless of what an assistant said.
546 +
- **Request review.** Under Reviewers on the proposal page, the author or a writer sees an **ask NAME** button per assistant the repository may use. Asking records the event (`asked NAME to review`) and the audit entry (`assistant.asked`), then a background task builds the context (title, description, the list of changes, the diff of each changed file as `-`/`+` lines per hunk, binary files noted, the whole cut to the assistant's context budget less the prompt), prefixes a fixed system prompt (who it is, that it answers for the person who asked, that it cannot approve, the shape of a review) with the levels' prompts, calls the endpoint, and records the answer as a review of verdict `comment` whose author is the person who asked and whose **via** (migration 0036) is the assistant, shown as "bob ↳ agent Claude reviewed" in the timeline, exactly as agent-signed changes are shown (§6). An assistant never approves: approvals count only people, and an assistant's review never replaces the asker's own latest verdict. The proposal's author is told as for any review; the `review` webhook carries `via`. No answer leaves an event (`NAME did not answer: why`) and no review.
547 +
- **Draft the description.** The new-proposal form (anyone who may open one) and an open proposal's editor (the author or a maintainer) have a **draft with NAME** button per assistant. It asks the assistant, now, for a description from the title so far, the changes (titles and the first lines of their messages) and the diff, under a fixed system prompt (description field only, what and why, what to look at, how to verify) plus the levels' prompts, and puts the answer in the editor with a note; nothing is saved until the person updates or opens the proposal. The audit log records `assistant.drafted`; the same per-repository hourly limit applies.
548 +
- **Limits.** One request per assistant per proposal at a time, 30 assistant calls per repository per hour (in memory), a 60-second call timeout, answers kept to 64 KiB, and a context budget (characters) per assistant.
549 +
- **API.** `POST /repos/{o}/{n}/proposals/{n}/review-requests {"assistant": NAME}` (the author or a writer; 202 when started, 409 when that assistant is already answering or the repository is over its hourly limit, 422 for a name the repository cannot use); reviews carry `via`. Settings endpoints are planned. Hub actions by an assistant are attributed "ASSISTANT via WHO" everywhere, so a project can require that a human has approved regardless of what an assistant said.
550 550
551 551
## 14. Git interoperability
552 552
…
145 unchanged lines
modified templates/help.html +1 −0
…
64 unchanged lines
65 65
    <li>Review comments are anchored to lines' atoms, so they stay on the right line through later pushes.</li>
66 66
    <li>An approval can be a <strong>seal</strong>: your signature over the proposal's changes (<code>oxy seal</code>), which anyone can check offline.</li>
67 67
    <li>Land from the page, or from a client with <code>oxy proposal land URL</code>, which signs the landing with your own key.</li>
68 +
    <li>Where the site, an organisation or a repository has set up <strong>AI assistants</strong> (Settings), you can ask one to review under Reviewers, or have it draft the description; its answer is recorded as yours <em>via</em> the assistant, with an <span class="tag">agent</span> badge. Only people approve.</li>
68 69
  </ul>
69 70
</section>
70 71
…
59 unchanged lines
modified templates/proposal.html +6 −4
…
86 unchanged lines
87 87
          </div></details></div>
88 88
      <div class="tl-view">{% if p.body.is_empty() %}<p class="tl-empty mute">No description.</p>{% else %}<article class="doc tl-doc">{{ body_html|safe }}</article>{% endif %}</div>
89 89
      {% if can_edit %}
90 -
      <form class="tl-editform" id="edit" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit">
90 +
      <form class="tl-editform{% if draft.is_some() %} open{% endif %}" id="edit" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit">
91 91
        <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
92 92
        <div class="edit-row"><label for="etitle">Title</label><input type="text" id="etitle" name="title" value="{{ p.title }}"></div>
93 93
        <div class="edit-row"><label for="etarget">Into bookmark</label><input type="text" id="etarget" name="target" value="{{ p.target }}"></div>
94 -
        <textarea id="ebody" name="body" class="md" rows="10" aria-label="Description (Markdown)">{{ p.body }}</textarea>
95 -
        <div class="tl-actions"><a class="go" href="#description">Cancel</a> <button type="submit" class="primary-btn">Update proposal</button></div>
94 +
        <textarea id="ebody" name="body" class="md" rows="10" aria-label="Description (Markdown)">{% if let Some(d) = draft %}{{ d }}{% else %}{{ p.body }}{% endif %}</textarea>
95 +
        {% if draft.is_some() %}<p class="small mute">Drafted by an assistant; nothing is saved until you update the proposal.</p>{% endif %}
96 +
        <div class="tl-actions"><a class="go" href="#description">Cancel</a> {% for a in assistants %}<button type="submit" class="small" formaction="{{ repo.url }}/proposal/{{ p.number }}/draft" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before saving"><span class="tag">agent</span> draft with {{ a }}</button> {% endfor %}<button type="submit" class="primary-btn">Update proposal</button></div>
96 97
      </form>
97 98
      {% endif %}
98 99
    </div>
…
11 unchanged lines
110 111
  <div class="tl-item review-{{ r.row.verdict }}">
111 112
    <img class="tl-avatar" src="/avatar/{{ r.row.author }}" alt="" width="36" height="36">
112 113
    <div class="tl-card">
113 -
      <div class="tl-head"><a href="/{{ r.row.author }}"><strong>{{ r.row.author }}</strong></a> {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}reviewed{% endif %} <time title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time></div>
114 +
      <div class="tl-head"><a href="/{{ r.row.author }}"><strong>{{ r.row.author }}</strong></a>{% if !r.row.via.is_empty() %} <span class="mute" aria-hidden="true">↳</span> <span class="tag" title="An AI assistant answered for {{ r.row.author }}, who asked it">agent</span> <strong>{{ r.row.via }}</strong>{% endif %} {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}reviewed{% endif %} <time title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time></div>
114 115
      {% if !r.row.body.is_empty() %}<div class="tl-text">{{ r.body_html|safe }}</div>{% endif %}
115 116
    </div>
116 117
  </div>
…
86 unchanged lines
203 204
        <div class="menu-foot"><button type="submit" class="small primary-btn">Apply</button></div>
204 205
      </form></details>{% endif %}</div>
205 206
    {% if requested.is_empty() %}<p class="side-empty">No reviews requested</p>{% else %}<ul class="side-people">{% for r in requested %}<li><a href="/{{ r }}"><img class="avatar xs" src="/avatar/{{ r }}" alt="" loading="lazy"> {{ r }}</a> <span class="side-note-inline">awaiting review</span></li>{% endfor %}</ul>{% endif %}
207 +
    {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/assistant"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="assistant" value="{{ a }}"><button type="submit" title="Ask {{ a }}, an AI assistant, to review; its answer is recorded as your review via {{ a }}"><span class="tag">agent</span> ask {{ a }}</button></form>{% endfor %}</div>{% endif %}
206 208
  </section>
207 209
  <section class="side-sec">
208 210
    <div class="side-head"><h3>Labels</h3>
…
40 unchanged lines
modified templates/proposal_new.html +3 −2
…
22 unchanged lines
23 23
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
24 24
  <input type="hidden" name="source" value="{{ source }}">
25 25
  <input type="hidden" name="target" value="{{ target }}">
26 -
  <label for="title">Title</label><input type="text" id="title" name="title" required value="{% if preview.len() == 1 %}{{ preview[0].title }}{% endif %}">
27 -
  <label for="body">Description (Markdown)</label><textarea id="body" name="body" class="md" rows="8"></textarea>
26 +
  <label for="title">Title</label><input type="text" id="title" name="title" required value="{% if !title.is_empty() %}{{ title }}{% else if preview.len() == 1 %}{{ preview[0].title }}{% endif %}">
27 +
  <label for="body">Description (Markdown)</label><textarea id="body" name="body" class="md" rows="8">{{ body }}</textarea>
28 +
  {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<button type="submit" class="small" formaction="{{ repo.url }}/proposals/new/draft" formnovalidate name="assistant" value="{{ a }}" title="{{ a }}, an AI assistant, drafts the description from the changes; you edit it before opening"><span class="tag">agent</span> draft with {{ a }}</button>{% endfor %}</div>{% endif %}
28 29
  <div class="choice"><label><input type="checkbox" name="draft" value="1"> draft: not ready for review</label></div>
29 30
  <button type="submit">OPEN PROPOSAL</button>
30 31
</form>
…
4 unchanged lines
modified templates/style.css +4 −2
…
713 unchanged lines
714 714
.side-note-inline { font-size: 12px; color: var(--mute); font-weight: 400; }
715 715
.side-list li { margin: 0 0 4px; }
716 716
.side-list form.inline { margin-left: 6px; }
717 +
.side-assist { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
718 +
.side-assist form.inline { display: inline; margin: 0; }
717 719
/* Forms inside a settings panel fill it, and a form that follows a table
718 720
   (Add a runner) stands off it (oxygenhub#99). */
719 721
.panel form.card { max-width: none; }
…
12 unchanged lines
732 734
.menu-action { all: unset; display: block; box-sizing: border-box; width: 100%; padding: 6px 14px; font-size: 13px; color: var(--ink); cursor: pointer; }
733 735
.menu-action:hover { background: var(--soft); color: var(--ink); }
734 736
.tl-editform { display: none; padding: 12px 14px; }
735 -
.tl-editform:target { display: block; }
736 -
.tl-card:has(.tl-editform:target) .tl-view { display: none; }
737 +
.tl-editform:target, .tl-editform.open { display: block; }
738 +
.tl-card:has(.tl-editform:target) .tl-view, .tl-card:has(.tl-editform.open) .tl-view { display: none; }
737 739
.edit-row { display: grid; grid-template-columns: 110px 1fr; align-items: center; gap: 10px; margin-bottom: 8px; }
738 740
.edit-row label { margin: 0; font-size: 13px; font-weight: 600; color: var(--ink); text-transform: none; letter-spacing: 0; }
739 741
.tl-editform .tl-actions { margin-top: 10px; align-items: center; }
…
46 unchanged lines