OXYGENHUB

Resolve a proposal's conflicts on the web: the hub brings the target into the source bookmark, as oxy merge would, and opens the resolver; the proposal follows the resolution (closes #337) 217814c239f0

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0b749dc9cb02
When
· 2026-10-27 18:19 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
217814c239f0abbe3bdb29f3ae45043442cb593dbda204b428836dc5af2732c5
Depends on
145306044896 1f1eb06a9009 2a8b621676b4 351cf57e5608 86a69b53aff0 8933faff2043 a7e6e8e6094c b5668108487f d23464c0ac60 de505df5cf8d
Depended on by
42cd46c9b09e
In bookmarks
main
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
12 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A proposal's conflicts are resolved on the web (#337).** A proposal whose landing would conflict has RESOLVE ON THE WEB for those who may push to its source: the hub brings the target into the source bookmark, as `oxy merge TARGET` would there (signed as the user's delegate, or unsigned where no trust policy asks for more), and opens the resolver on the first conflicted file. The resolution is committed to the source, so the proposal follows it and lands. A proposal from a fork is resolved in the fork.
8 +
7 9
- **A default bookmark must exist, and bookmarks are made on the web (#345).** Settings refused nothing, so a default named before it existed (doomsdayone/tetriblox's `develop`) left the repository's pages saying `"develop" is not a change`. Settings now refuse a new default that is not a bookmark (an empty repository may name any, for its first push) and list those that are; a missing default leaves the overview showing main, or the first bookmark, with a note; an unknown name is "not a bookmark, tag or change of this repository". Bookmarks › New bookmark makes one from a bookmark, tag or change, as the user's delegate or, without a trust policy, unsigned as a push would be, so a proposal can then target it.
8 10
9 11
- **Iterations on project boards (#343).** A board can run an iteration: a name and its first and last days. The board shows it above the columns, day N of M and the cards done so far, with a burn-up of cards done per day. Closing it archives the cards in the Done column (the one that takes finished work, or else the last) under it; its page, linked from "past iterations", lists them, restorable, with its burn-up. One iteration is open at a time. Migration 0100.
…
374 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
284 unchanged lines
285 285
        .route("/{owner}/{name}/proposal/{number}", get(proposals::show))
286 286
        .route("/{owner}/{name}/proposal/{number}/comment", post(proposals::comment))
287 287
        .route("/{owner}/{name}/proposal/{number}/comment/{id}/resolve", post(proposals::resolve_comment))
288 +
        .route("/{owner}/{name}/proposal/{number}/resolve", post(proposals::resolve_on_web))
288 289
        .route("/{owner}/{name}/proposal/{number}/review", post(proposals::review))
289 290
        .route("/{owner}/{name}/proposal/{number}/reviewers", post(proposals::reviewers))
290 291
        .route("/{owner}/{name}/proposal/{number}/assistant", post(proposals::assistant))
…
673 unchanged lines
modified crates/hub/src/web/proposals.rs +67 −0
…
790 unchanged lines
791 791
    since_landing: Vec<ChangeView>,
792 792
    can_land: bool,
793 793
    can_land_over_checks: bool,
794 +
    /// May bring the target into the source and resolve on the web (#337).
795 +
    can_resolve: bool,
794 796
    can_auto_land: bool,
795 797
    hub_can_land: bool,
796 798
    notice: Option<String>,
…
113 unchanged lines
910 912
    // checks pass (#261).
911 913
    let can_auto_land = role >= Role::Write && p.is_open() && !v.blockers.is_empty();
912 914
    // Checks alone in the way: a maintainer may land over them (#261).
915 +
    let can_resolve = role >= Role::Write && p.is_open() && p.source_kind == "bookmark";
913 916
    let can_land_over_checks = role >= Role::Maintain && p.is_open() && v.checks_blocker.is_some() && v.blockers.len() == 1;
914 917
    let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write);
915 918
    let requested = pdb::review_requests(&app.db, p.id).await.unwrap_or_default();
…
82 unchanged lines
998 1001
        deletable_source,
999 1002
        can_land,
1000 1003
        can_land_over_checks,
1004 +
        can_resolve,
1001 1005
        can_auto_land,
1002 1006
        hub_can_land,
1003 1007
        notice,
…
312 unchanged lines
1316 1320
    Ok(back(&owner, &name, number, "conversation", Some(note.into()), None))
1317 1321
}
1318 1322
1323 +
/// `POST …/proposal/{n}/resolve` (#337): bring a conflicted proposal's
1324 +
/// target into its source bookmark on the hub, as `oxy merge TARGET` on the
1325 +
/// source would, so the conflict is on the source; then open the resolver
1326 +
/// on its first conflicted file. The proposal follows its source, so the
1327 +
/// resolution committed there clears it. For those who may push to the
1328 +
/// source, and a source in this repository.
1329 +
pub async fn resolve_on_web(
1330 +
    State(app): State<Shared>,
1331 +
    viewer: Viewer,
1332 +
    Path((owner, name, number)): Path<(String, String, i64)>,
1333 +
    Form(f): Form<LandForm>,
1334 +
) -> Result<Response, Error> {
1335 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer);
1336 +
    let me = require(&viewer, &shell)?;
1337 +
    csrf(&viewer, &f.csrf, &shell)?;
1338 +
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
1339 +
    let p = pdb::get(&app.db, row.id, number)
1340 +
        .await
1341 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
1342 +
        .filter(|p| p.is_open())
1343 +
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1344 +
    if p.source_kind != "bookmark" {
1345 +
        let msg = "Its source is not a bookmark of this repository: resolve it there, then push.".to_string();
1346 +
        return Ok(back(&owner, &name, number, "conversation", None, Some(msg)));
1347 +
    }
1348 +
    let (source, target) = (p.source_bookmark.clone(), p.target.clone());
1349 +
    let (s, t) = (source.clone(), target.clone());
1350 +
    let union = app
1351 +
        .repos
1352 +
        .read(&repo, move |r, _| {
1353 +
            let view = r.view()?;
1354 +
            let (Some(ours), Some(theirs)) = (view.bookmarks.get(&s).copied(), view.bookmarks.get(&t).copied()) else { return Ok(None) };
1355 +
            let mut all = r.store().get_state(&ours)?.heads;
1356 +
            all.extend(r.store().get_state(&theirs)?.heads);
1357 +
            all.sort();
1358 +
            all.dedup();
1359 +
            let state = r.load_state(&all)?;
1360 +
            Ok(Some(state.maximal(&all)))
1361 +
        })
1362 +
        .await
1363 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1364 +
    let Some(union) = union else {
1365 +
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("{source} or {target} is not a bookmark any more."))));
1366 +
    };
1367 +
    if let Err(why) = super::refs::set_bookmark(&app, &repo, me.account.id, &source, union.clone()).await {
1368 +
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("Could not bring {target} into {source}: {why}"))));
1369 +
    }
1370 +
    let detail = format!("{target} into {source} for proposal #{number}");
1371 +
    accounts::audit(&app.db, Some(&me.account), "proposal.merged_target", &format!("{owner}/{name}"), &detail).await.ok();
1372 +
    let s = source.clone();
1373 +
    let first = app
1374 +
        .repos
1375 +
        .read(&repo, move |r, cache| Ok(view::conflicts(r, cache)?.into_iter().find(|c| c.bookmark == s).map(|c| c.path)))
1376 +
        .await
1377 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1378 +
    Ok(match first {
1379 +
        Some(path) => Redirect::to(&format!("/{owner}/{name}/resolve/{source}/{path}")).into_response(),
1380 +
        None => {
1381 +
            back(&owner, &name, number, "conversation", Some(format!("Brought {target} into {source}: nothing left to resolve.")), None)
1382 +
        }
1383 +
    })
1384 +
}
1385 +
1319 1386
/// Why a proposal did not land.
1320 1387
pub enum Landing {
1321 1388
    NotFound,
…
835 unchanged lines
modified crates/hub/src/web/refs.rs +37 −26
…
187 unchanged lines
188 188
        Ok(_) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("{from} holds no changes to start from."))).await,
189 189
        Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await,
190 190
    };
191 -
    // As the user's delegate when they have one; otherwise unsigned, which
192 -
    // a repository without a trust policy takes, as a push without one.
193 -
    let made = match super::edit::signer_for(&app, me.account.id).await {
194 -
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, &repo, bookmark.clone(), heads.clone(), signer)
195 -
            .await
196 -
            .map(|_| ())
197 -
            .map_err(|e| e.to_string()),
198 -
        Err(why) => {
199 -
            let (target, at) = (bookmark.clone(), heads.clone());
200 -
            let unsigned = app
201 -
                .repos
202 -
                .write(&repo, move |r, _| {
203 -
                    let update = r.host_ref_update(&target, &at, None)?;
204 -
                    let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?;
205 -
                    Ok(match report.results.first() {
206 -
                        Some(res) if res.ok => Ok(()),
207 -
                        Some(res) => Err(res.reason.clone()),
208 -
                        None => Err("nothing happened".to_string()),
209 -
                    })
210 -
                })
211 -
                .await
212 -
                .map_err(|e| e.to_string())
213 -
                .and_then(|r| r);
214 -
            unsigned.map_err(|refused| format!("{refused}. {why}"))
215 -
        }
216 -
    };
191 +
    let made = set_bookmark(&app, &repo, me.account.id, &bookmark, heads.clone()).await;
217 192
    if let Err(e) = made {
218 193
        return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not create {bookmark}: {e}"))).await;
219 194
    }
…
26 unchanged lines
246 221
        .ok();
247 222
    let notice = format!("Created {bookmark} from {from}. Propose changes into it, or make it the default in Settings.");
248 223
    bookmarks_page(&app, shell, repo, row, role, Some(notice), None).await
224 +
}
225 +
226 +
/// Move (or make) `bookmark` at `heads` from the web: as the user's
227 +
/// delegate when they have one, otherwise unsigned, which a repository
228 +
/// without a trust policy takes, as a push without one. `Err` says why not.
229 +
pub async fn set_bookmark(
230 +
    app: &Shared,
231 +
    repo: &crate::repos::RepoName,
232 +
    account_id: i64,
233 +
    bookmark: &str,
234 +
    heads: Vec<oxy_core::hash::ChangeHash>,
235 +
) -> Result<(), String> {
236 +
    match super::edit::signer_for(app, account_id).await {
237 +
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, bookmark.to_string(), heads, signer)
238 +
            .await
239 +
            .map(|_| ())
240 +
            .map_err(|e| e.to_string()),
241 +
        Err(why) => {
242 +
            let target = bookmark.to_string();
243 +
            let unsigned = app
244 +
                .repos
245 +
                .write(repo, move |r, _| {
246 +
                    let update = r.host_ref_update(&target, &heads, None)?;
247 +
                    let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?;
248 +
                    Ok(match report.results.first() {
249 +
                        Some(res) if res.ok => Ok(()),
250 +
                        Some(res) => Err(res.reason.clone()),
251 +
                        None => Err("nothing happened".to_string()),
252 +
                    })
253 +
                })
254 +
                .await
255 +
                .map_err(|e| e.to_string())
256 +
                .and_then(|r| r);
257 +
            unsigned.map_err(|refused| format!("{refused}. {why}"))
258 +
        }
259 +
    }
249 260
}
250 261
251 262
#[derive(Template)]
…
180 unchanged lines
modified crates/hub/tests/edits.rs +92 −0
…
547 unchanged lines
548 548
    assert!(v.iter().all(|s| matches!(s, oxy_core::repo::SignatureStatus::Valid { .. })), "{v:?}");
549 549
}
550 550
551 +
/// #337: a proposal whose landing would conflict is resolved on the web:
552 +
/// its target is brought into its source bookmark on the hub, the resolver
553 +
/// opens on the conflicted file, and once resolved the proposal lands.
554 +
#[tokio::test(flavor = "multi_thread")]
555 +
async fn a_proposals_conflicts_are_resolved_on_the_web() {
556 +
    hermetic();
557 +
    let dir = tempfile::tempdir().unwrap();
558 +
    let mut config = Config::load(dir.path()).unwrap();
559 +
    config.registration.setup_code = "test-setup".into();
560 +
    config.auth.push_tokens = vec!["site".into()];
561 +
    config.registration.mode = "open".into();
562 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
563 +
    let port = listener.local_addr().unwrap().port();
564 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
565 +
    let app = oxygenhub::build(config).await.unwrap();
566 +
    let router = oxygenhub::router(app.clone());
567 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
568 +
    let base = format!("http://127.0.0.1:{port}");
569 +
    let app = &app;
570 +
    let r =
571 +
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
572 +
            .await;
573 +
    let andy = r.cookie.unwrap();
574 +
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
575 +
    let repo = RepoName::parse("andy/demo").unwrap();
576 +
    app.repos.create(&repo, None).unwrap();
577 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
578 +
    let work = tempfile::tempdir().unwrap();
579 +
    let mut client = Repo::init(work.path()).unwrap();
580 +
    client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
581 +
    client.config.set("core.sign", "change").unwrap();
582 +
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
583 +
    client.config.set("remote.origin.token", "site").unwrap();
584 +
    client.save_config().unwrap();
585 +
    write(work.path(), "a.txt", b"one\ntwo\n");
586 +
    client.snapshot("t").unwrap();
587 +
    client.commit("first", &[]).unwrap();
588 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
589 +
    client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap();
590 +
    let key = client.config.author_key.unwrap();
591 +
    let id = client.identity(&key).unwrap();
592 +
    db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap();
593 +
    let csrf = csrf_of(&send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await.body);
594 +
595 +
    // Two proposals change the same line; the first lands.
596 +
    for (b, text) in [("left", "Left\ntwo\n"), ("right", "Right\ntwo\n")] {
597 +
        let r = send(
598 +
            app,
599 +
            "POST",
600 +
            "/andy/demo/edit/main",
601 +
            Some(&andy),
602 +
            Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))),
603 +
        )
604 +
        .await;
605 +
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
606 +
    }
607 +
    let r = send(app, "POST", "/andy/demo/proposal/1/land", Some(&andy), Some(&format!("_csrf={csrf}"))).await;
608 +
    assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "{:?}", r.location);
609 +
610 +
    // The second would conflict: the page offers to resolve it here.
611 +
    let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await.body;
612 +
    assert!(page.contains("Landing would carry 1 conflict") && page.contains("RESOLVE ON THE WEB"), "{page}");
613 +
    let r = send(app, "POST", "/andy/demo/proposal/2/resolve", Some(&andy), Some(&format!("_csrf={csrf}"))).await;
614 +
    assert_eq!(r.location.as_deref(), Some("/andy/demo/resolve/right/a.txt"), "{}", r.body);
615 +
    // main is in right now, so right carries the conflict itself.
616 +
    let host = app.repos.open(&repo).unwrap();
617 +
    let heads = |b: &str| host.store().get_state(&host.view().unwrap().bookmarks[b]).unwrap().heads;
618 +
    let right = host.load_state(&heads("right")).unwrap();
619 +
    assert!(heads("main").iter().all(|h| right.contains(h)), "main brought into right");
620 +
621 +
    // Resolved on the resolver, the proposal lands.
622 +
    let page = send(app, "GET", "/andy/demo/resolve/right/a.txt", Some(&andy), None).await.body;
623 +
    let state = {
624 +
        let i = page.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
625 +
        page[i..i + 64].to_string()
626 +
    };
627 +
    let r = send(
628 +
        app,
629 +
        "POST",
630 +
        "/andy/demo/resolve/right/a.txt",
631 +
        Some(&andy),
632 +
        Some(&format!("_csrf={csrf}&state={state}&r1=text&t1={}&message={}", enc("Left and right"), enc("Resolve a.txt"))),
633 +
    )
634 +
    .await;
635 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
636 +
    let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await.body;
637 +
    assert!(!page.contains("Landing would carry"), "{page}");
638 +
    let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={csrf}"))).await;
639 +
    assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "{:?}", r.location);
640 +
    assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "Left and right\ntwo\n");
641 +
}
642 +
551 643
/// A rotation reaches the hub with the next push: an author whose identity
552 644
/// is a successor of a linked one, with the same key pair, is linked
553 645
/// without another challenge. An agent's change shows who signed for the
…
383 unchanged lines
modified templates/proposal.html +1 −1
…
18 unchanged lines
19 19
<span class="chain-arrow">→</span> <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a>{% if p.state == "landed" %} <span class="chain-state st-landed">landed</span>{% endif %}</p>{% endif %}
20 20
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
21 21
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
22 -
{% if conflicted %}<div class="strip bad">Landing would carry {{ conflicts.len()|plural("conflict", "conflicts") }}. Any collaborator can push a resolution change to the source; the proposal follows it. <a href="?tab=files#conflicts">See them</a></div>{% endif %}
22 +
{% if conflicted %}<div class="strip bad">Landing would carry {{ conflicts.len()|plural("conflict", "conflicts") }}. Any collaborator can push a resolution change to the source; the proposal follows it. <a href="?tab=files#conflicts">See them</a>{% if can_resolve %}<form class="inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/resolve"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> <button type="submit" class="small" title="Brings {{ p.target }} into {{ p.source_bookmark }} here, as oxy merge would, then opens the resolver on the first conflicted file">RESOLVE ON THE WEB</button></form>{% endif %}</div>{% endif %}
23 23
{% if landed_already && p.state != "landed" %}<div class="strip warn">Every change is already in {{ p.target }}.</div>{% endif %}
24 24
{% if p.state == "landed" && !since_landing.is_empty() %}<div class="strip warn"><code>{{ p.source_bookmark }}</code> has {{ since_landing.len()|plural("change", "changes") }} since this landed, not on <code>{{ p.target }}</code>: {% for c in since_landing %}<a href="{{ repo.url }}/change/{{ c.hash }}"><code>{{ c.hash|short }}</code></a> {{ c.title }}{% if !loop.last %}, {% endif %}{% endfor %}. <a href="{{ repo.url }}/proposals/new?source={{ p.source_bookmark }}&amp;target={{ p.target }}">Open a new proposal</a> to take {% if since_landing.len() == 1 %}it{% else %}them{% endif %} there.</div>{% endif %}
25 25
<div class="bar">
…
241 unchanged lines