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 }}&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 |