{"messages":[{"content":"You are ranking individual git commits to an open source project.\nCompare these two commits. Decide which commit contributed more.\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[04b81f91] Fix vote compare highlighting\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 588cf62bcea608ed4ede363810f022a67b43b960..e180a0ca542a33e2300c0a4809e6b9cfee07ecfe 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -291,18 +291,17 @@ fn rank_list(\n label: &str,\n items: &[RankedItem],\n start_rank: usize,\n- total_ranked: usize,\n- ordinal_offset: usize,\n highlighted: &HashSet,\n tree: &GlobalTree,\n ) -> Markup {\n+ let group_len = items.len();\n html! {\n @if !items.is_empty() {\n h3 class=\"rank-heading muted small\" { (label) }\n ol class=\"rank-list\" {\n @for (i, r) in items.iter().enumerate() {\n @let href = item_href(&r.item);\n- @let style = rank_row_style(parent, ordinal_offset + i, total_ranked);\n+ @let style = rank_row_style(parent, i, group_len);\n @let class = rank_row_class(&r.item, highlighted);\n li class=(class)\n data-rank-item=(r.item.as_str())\n@@ -420,8 +419,6 @@ pub fn ranking_panel_with_highlights(\n \n let has_ranked = !ranked_groups.is_empty();\n let multi = ranked_groups.len() > 1;\n- let total_ranked: usize = ranked_groups.iter().map(|g| g.len()).sum();\n- let mut ordinal_offset = 0usize;\n \n html! {\n section id=\"ranking-panel\" class=\"demo-panel\" {\n@@ -436,8 +433,7 @@ pub fn ranking_panel_with_highlights(\n } @else {\n @for (gi, ranked) in ranked_groups.iter().enumerate() {\n @let label = if multi { format!(\"Ranking group {}\", gi + 1) } else { \"Ranking\".to_string() };\n- (rank_list(item, &label, ranked, 1, total_ranked, ordinal_offset, highlighted, tree))\n- @let _ = { ordinal_offset += ranked.len(); };\n+ (rank_list(item, &label, ranked, 1, highlighted, tree))\n }\n (unranked_list(\"Unranked\", &unranked, highlighted, tree))\n }\n@@ -520,7 +516,20 @@ pub async fn browse(State(state): State, uri: Uri) -> impl IntoRespons\n \n #[cfg(test)]\n mod tests {\n- use super::SORTER_UI_JS;\n+ use super::{rank_row_style, SORTER_UI_JS};\n+ use crate::path_types::ItemId;\n+\n+ #[test]\n+ fn rank_row_style_gradients_per_group_not_globally() {\n+ let parent = ItemId::opaque(\"test-scope\");\n+ let first_in_four = rank_row_style(&parent, 0, 4);\n+ let last_in_four = rank_row_style(&parent, 3, 4);\n+ let first_in_two = rank_row_style(&parent, 0, 2);\n+ let last_in_two = rank_row_style(&parent, 1, 2);\n+ assert_eq!(first_in_four, first_in_two);\n+ assert_eq!(last_in_four, last_in_two);\n+ assert_ne!(first_in_four, last_in_four);\n+ }\n \n #[test]\n fn vote_slider_left_position_favors_left_item() {\ndiff --git a/server/src/html/vote.rs b/server/src/html/vote.rs\nindex 483c7ff8cd35266744957cf0087f2b5f76f1a237..30dab303b9d1e763a5b9261dffff13a778e3e4e3 100644\n--- a/server/src/html/vote.rs\n+++ b/server/src/html/vote.rs\n@@ -19,7 +19,7 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n-use super::{breadcrumb_path, item_href, layout};\n+use super::{breadcrumb_path, layout};\n \n #[derive(Debug, Deserialize)]\n pub struct VoteQuery {\n@@ -59,17 +59,6 @@ fn child_title(tree: &GlobalTree, id: &ItemId) -> String {\n .unwrap_or_else(|| display_label(id))\n }\n \n-fn ratio_pct(ratio_left: i32, ratio_right: i32) -> f64 {\n- let l = ratio_left.max(0) as f64;\n- let r = ratio_right.max(0) as f64;\n- let sum = l + r;\n- if sum <= 0.0 {\n- 50.0\n- } else {\n- (l / sum) * 100.0\n- }\n-}\n-\n fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i32, i32) {\n match (v.a.as_str(), v.b.as_str()) {\n (a, b) if a == page_left.as_str() && b == page_right.as_str() => {\n@@ -94,6 +83,37 @@ fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec &'static str {\n+ if r_left > r_right {\n+ \"left\"\n+ } else if r_right > r_left {\n+ \"right\"\n+ } else {\n+ \"even\"\n+ }\n+}\n+\n+fn winner_text(r_left: i32, r_right: i32) -> &'static str {\n+ match winner_side(r_left, r_right) {\n+ \"left\" => \"left wins\",\n+ \"right\" => \"right wins\",\n+ _ => \"tie\",\n+ }\n+}\n+\n+/// Map stored ratios to the live slider position (0 = full left, 100 = full right).\n+/// Matches `sorter_ui.js`: `left = 100 - v`, `right = v`.\n+fn slider_value_from_ratios(r_left: i32, r_right: i32) -> i32 {\n+ let l = r_left.max(0) as f64;\n+ let r = r_right.max(0) as f64;\n+ let sum = l + r;\n+ if sum <= 0.0 {\n+ return 50;\n+ }\n+ ((r / sum) * 100.0).round().clamp(0.0, 100.0) as i32\n+}\n+\n fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right: &ItemId) -> Markup {\n let mut votes = edge_votes(group, left, right);\n votes.sort_by(|a, b| b.ts.cmp(&a.ts));\n@@ -106,17 +126,26 @@ fn vote_edge_history(tree: &GlobalTree, group: &GroupState, left: &ItemId, right\n h3 class=\"vote-edge-history-title\" {\n \"votes on this pair\"\n }\n+ p class=\"muted small vote-edge-legend\" {\n+ (format!(\"left: {legend_left} — right: {legend_right}\"))\n+ }\n ul class=\"vote-edge-history\" {\n @for v in &votes {\n @let (r_left, r_right) = ratios_for_page(v, left, right);\n- @let pct = ratio_pct(r_left, r_right);\n+ @let slider_val = slider_value_from_ratios(r_left, r_right);\n+ @let side = winner_side(r_left, r_right);\n+ @let label = winner_text(r_left, r_right);\n li class=\"vote-edge-history-row\" {\n div class=\"vote-edge-meta\" {\n span class=\"vote-edge-ratio\" { (format!(\"{}:{}\", r_left, r_right)) }\n+ span class=\"vote-edge-winner muted small\" { \" · \" (label) }\n }\n- div class=\"ratio-bar vote-edge-bar\" aria-hidden=\"true\" {\n- div class=\"ratio-left\" style={(format!(\"width: {:.3}%;\", pct))} {}\n- div class=\"ratio-right\" style={(format!(\"width: {:.3}%;\", 100.0 - pct))} {}\n+ label class=\"vote-hud-slider vote-edge-slider\" aria-hidden=\"true\" {\n+ input type=\"range\" class=\"vote-edge-range\" min=\"0\" max=\"100\" value=(slider_val)\n+ data-winner=(side)\n+ style={(format!(\"--vote-slider-pct: {}%;\", slider_val))}\n+ disabled\n+ tabindex=\"-1\";\n }\n }\n }\n@@ -283,7 +312,6 @@ pub async fn vote_page(\n span class=\"vote-compare-vs\" { \"vs\" }\n (vote_compare_item_card(&tree, &right, \"vote-compare-right\"))\n }\n- (vote_back_nav(&parent))\n div id=\"vote-edge-history-region\" {\n (edge_history)\n }\n@@ -308,3 +336,66 @@ pub async fn vote_page(\n )\n .into_response()\n }\n+\n+#[cfg(test)]\n+mod polarity_tests {\n+ use super::*;\n+ use crate::ranking::ranked_items;\n+ use crate::reducer::GlobalTree;\n+\n+ fn id(s: &str) -> ItemId {\n+ ItemId::parse(s).unwrap()\n+ }\n+\n+ /// The page's left number must always equal the vote's weight for the\n+ /// item shown on the left, regardless of which order the vote stored a/b.\n+ #[test]\n+ fn ratios_for_page_orients_to_page_left() {\n+ let left = id(\"left_item\");\n+ let right = id(\"right_item\");\n+\n+ // Stored a == page left: keep order.\n+ let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));\n+\n+ // Stored a == page right: swap so left stays left.\n+ let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();\n+ assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));\n+ }\n+\n+ #[test]\n+ fn winner_side_follows_larger_ratio() {\n+ assert_eq!(winner_side(9, 1), \"left\");\n+ assert_eq!(winner_side(1, 9), \"right\");\n+ assert_eq!(winner_side(1, 1), \"even\");\n+ }\n+\n+ #[test]\n+ fn slider_value_matches_hud_mapping() {\n+ assert_eq!(slider_value_from_ratios(9, 1), 10);\n+ assert_eq!(slider_value_from_ratios(1, 4), 80);\n+ assert_eq!(slider_value_from_ratios(1, 1), 50);\n+ }\n+\n+ /// End-to-end polarity invariant: a vote that favours the LEFT item (higher\n+ /// `ratio_left`, recorded as the RPC's `a`) must make that item rank #1.\n+ /// This is the property the UI must preserve: sliding left => left wins.\n+ #[test]\n+ fn sliding_left_makes_left_item_win_ranking() {\n+ let parent = id(\"scope\");\n+ let left = id(\"left_item\");\n+ let right = id(\"right_item\");\n+\n+ // Slider dragged left yields e.g. 9:1 with a = left item.\n+ let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ let mut tree = GlobalTree::new();\n+ tree.apply_vote(&parent, vote);\n+\n+ let group = &tree.get(&parent).unwrap().local_ranking;\n+ let ranked = ranked_items(group);\n+ assert_eq!(\n+ ranked[0].item, left,\n+ \"left item should rank first when ratio favours the left\"\n+ );\n+ }\n+}\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 5da967e8bfddc2bc34662fced841860f63f1ef36..bdd3d931365703705be08537a07f3ccaf975d3ea 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -124,7 +124,7 @@ code {\n z-index: 1;\n box-shadow:\n inset 0 0 0 3px var(--accent),\n- inset 0 0 0 5px color-mix(in oklch, var(--rank-fg) 70%, transparent);\n+ inset 0 0 0 5px color-mix(in oklch, var(--rank-fg, var(--fg)) 70%, transparent);\n filter: saturate(1.15) brightness(1.04);\n }\n \n@@ -320,7 +320,7 @@ h1 {\n grid-template-columns: var(--vote-ratio-col) minmax(0, 1fr) var(--vote-actions-col);\n gap: 0.5rem 0.65rem;\n align-items: center;\n- --vote-ratio-col: 3.25rem;\n+ --vote-ratio-col: 12.5rem;\n --vote-actions-col: 11.5rem;\n }\n \n@@ -332,7 +332,7 @@ h1 {\n margin: 0;\n width: var(--vote-ratio-col);\n min-width: var(--vote-ratio-col);\n- text-align: center;\n+ text-align: right;\n white-space: nowrap;\n }\n \n@@ -474,6 +474,16 @@ h1 {\n --vote-track-muted: color-mix(in oklch, var(--muted) 55%, var(--bg));\n }\n \n+/* Center-anchored fill: the accent grows from the midpoint toward the thumb,\n+ so the highlighted mass always sits on the *winning* side (the side the\n+ thumb was dragged toward). Left winning => accent on the left half. */\n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-webkit-slider-runnable-track {\n+ height: 0.65rem;\n+ border-radius: 0;\n+ border: 2px solid var(--border);\n+ background: var(--vote-track-muted);\n+}\n+\n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-webkit-slider-runnable-track {\n height: 0.65rem;\n border-radius: 0;\n@@ -483,7 +493,9 @@ h1 {\n var(--vote-track-muted) 0%,\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--accent) var(--vote-slider-pct, 50%),\n- var(--accent) 100%\n+ var(--accent) 50%,\n+ var(--vote-track-muted) 50%,\n+ var(--vote-track-muted) 100%\n );\n }\n \n@@ -493,7 +505,9 @@ h1 {\n border: 2px solid var(--border);\n background: linear-gradient(\n to right,\n- var(--accent) 0%,\n+ var(--vote-track-muted) 0%,\n+ var(--vote-track-muted) 50%,\n+ var(--accent) 50%,\n var(--accent) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) 100%\n@@ -513,26 +527,35 @@ h1 {\n }\n \n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-moz-range-track,\n-.vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track {\n+.vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track,\n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-moz-range-track {\n height: 0.65rem;\n border-radius: 0;\n border: 2px solid var(--border);\n }\n \n+.vote-hud-slider input[type=\"range\"][data-winner=\"even\"]::-moz-range-track {\n+ background: var(--vote-track-muted);\n+}\n+\n .vote-hud-slider input[type=\"range\"][data-winner=\"left\"]::-moz-range-track {\n background: linear-gradient(\n to right,\n var(--vote-track-muted) 0%,\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--accent) var(--vote-slider-pct, 50%),\n- var(--accent) 100%\n+ var(--accent) 50%,\n+ var(--vote-track-muted) 50%,\n+ var(--vote-track-muted) 100%\n );\n }\n \n .vote-hud-slider input[type=\"range\"][data-winner=\"right\"]::-moz-range-track {\n background: linear-gradient(\n to right,\n- var(--accent) 0%,\n+ var(--vote-track-muted) 0%,\n+ var(--vote-track-muted) 50%,\n+ var(--accent) 50%,\n var(--accent) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) var(--vote-slider-pct, 50%),\n var(--vote-track-muted) 100%\n@@ -573,20 +596,23 @@ h1 {\n margin-bottom: 0.25rem;\n }\n \n-.ratio-bar {\n- display: flex;\n- height: 8px;\n- border-radius: 0;\n- overflow: hidden;\n- background: var(--border);\n+/* Read-only sliders in vote history — same track/thumb styling as the HUD. */\n+.vote-edge-slider {\n+ display: block;\n+ margin-top: 0.15rem;\n }\n \n-.ratio-left {\n- background: var(--accent);\n+.vote-edge-slider input[type=\"range\"] {\n+ pointer-events: none;\n+ cursor: default;\n+}\n+\n+.vote-edge-slider input[type=\"range\"]:disabled {\n+ opacity: 1;\n }\n \n-.ratio-right {\n- background: var(--muted);\n+.vote-edge-legend {\n+ margin: 0 0 0.5rem;\n }\n \n @media (max-width: 900px) {\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex f665b45f4fd4771427dc4c37e563e4976873beee..5d6d765f69f301722e59248c76e919043559fb78 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -138,6 +138,10 @@\n function update() {\n var v = parseInt(slider.value, 10);\n if (!Number.isFinite(v)) v = 50;\n+ // Slider position drives the colored fill (a center-anchored bar that\n+ // grows toward whichever side is winning — see sorter.css). Sliding the\n+ // thumb left raises the left number; left winning keeps the accent mass\n+ // on the left, matching the \"votes on this pair\" history bars.\n slider.style.setProperty('--vote-slider-pct', v + '%');\n slider.setAttribute('aria-valuenow', String(v));\n var left = Math.max(1, 100 - v);\n@@ -147,8 +151,12 @@\n right = right / divisor;\n if (leftInput) leftInput.value = String(left);\n if (rightInput) rightInput.value = String(right);\n- if (ratioDisplay) ratioDisplay.textContent = left + ':' + right;\n- slider.dataset.winner = left >= right ? 'left' : 'right';\n+ var winner = left > right ? 'left' : (right > left ? 'right' : 'even');\n+ slider.dataset.winner = winner;\n+ if (ratioDisplay) {\n+ var label = winner === 'even' ? 'tie' : (winner + ' wins');\n+ ratioDisplay.textContent = left + ':' + right + ' \\u00b7 ' + label;\n+ }\n }\n slider.addEventListener('input', update);\n update();\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[06b48801] Implement extensible URL foundation (#145)\n\n* Implement extensible URL foundation\n\nCo-authored-by: tommy \n\n* Make query params non-identity by default\n\nCo-authored-by: tommy \n\n* Fix external href helper test scope\n\nCo-authored-by: tommy \n\n* Avoid broken external previews for blocked hosts\n\nCo-authored-by: tommy \n\n* Clarify external empty state copy\n\nCo-authored-by: tommy \n\n* Add on-demand GitHub external resolver\n\nCo-authored-by: tommy \n\n* Add fenced JSON bodies for GitHub resolver\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide B — unified diff (full patch):\ndiff --git a/agents.md b/agents.md\nindex 59bd2174f4d2f972a45123fe10d408aa5881ee93..c66f33789441eea193b4354fce4c03b7fffdd639 100644\n--- a/agents.md\n+++ b/agents.md\n@@ -55,6 +55,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma\n | Ingests, grants, rooms, identity tokens, agent binds, redactions, etc. | **JSONL** | Appended in `server/src/api/rpc.rs`, `server/src/api/auth.rs` (and related paths) before updating `ReducerState` |\n | **`RoomMintInvite` links** | **RAM only** | `AppState.invites` — not appended as `InviteMinted` today; **lost on restart** (`server/src/state.rs`, `server/src/api/rpc.rs`). Event types `InviteMinted` / `InviteRedeemed` exist for replay and a possible future persisted mint (`server/src/reducer.rs`). |\n | **OAuth / pending sessions** | **RAM only** | `AppState.pending_sessions` (`server/src/state.rs`, `server/src/api/auth.rs`) |\n+| **External resolver cooldowns** | **RAM only** | `AppState.resolver_runs` — debounce/rate-limit guard for on-demand resolver buttons. Resolver results themselves are durable synthetic `Ingest` events in `events.jsonl`. |\n | **Reducer projection** | **Derived** | Rebuilt from log on startup; not separately persisted |\n \n If you add a new ephemeral map or start persisting something that was RAM-only, **update this table and the code comments** (`server/src/state.rs` is a good anchor).\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 7cbda3876451687aa7a55547fc06bbe86ac9d260..cd501ba6d4d656eabad03afed4583efdf885695d 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -18,14 +18,15 @@ use crate::{\n rpc::{rpc_post_redact, rpc_post_with_bearer, rpc_room_delete},\n },\n canonical_path::canonicalize_tag,\n+ external_resolver::resolve_github_children,\n+ html::vote_compare_post_success_js,\n html::{\n- fragment_new_thread_slot, login_to_post_hint_markup,\n- parse_html_ui_from_form, room_members_section_markup, thread_feed_html,\n- thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post,\n- thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room,\n- user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,\n+ fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form,\n+ room_members_section_markup, thread_feed_html, thread_feed_html_for_room,\n+ thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full,\n+ thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room,\n+ HtmlUiAction, JsBuilder, ThreadNav,\n },\n- html::vote_compare_post_success_js,\n reducer::{scope_from_room_wire, ScopeId},\n state::AppState,\n };\n@@ -104,26 +105,35 @@ async fn dispatch_ui_action(\n )\n .into_response();\n }\n- match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {\n- Ok(RpcResult::PostOk { .. }) => {\n- post_success_response(\n- state,\n- &room,\n- &thread_tag,\n- error_target.as_ref(),\n- form_id.as_ref(),\n- Some(session.username.as_str()),\n- )\n- .await\n- .into_response()\n- }\n+ match rpc_post_with_bearer(\n+ state,\n+ &session.bearer,\n+ room.clone(),\n+ thread_tag.clone(),\n+ text,\n+ )\n+ .await\n+ {\n+ Ok(RpcResult::PostOk { .. }) => post_success_response(\n+ state,\n+ &room,\n+ &thread_tag,\n+ error_target.as_ref(),\n+ form_id.as_ref(),\n+ Some(session.username.as_str()),\n+ )\n+ .await\n+ .into_response(),\n Ok(_) => form_js_error(\n error_target.as_ref(),\n \"unexpected response\",\n \"Post did not return PostOk.\",\n )\n .into_response(),\n- Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ Err((msg, hint)) => {\n+ form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\"))\n+ .into_response()\n+ }\n }\n }\n HtmlUiAction::CheckIngest {\n@@ -150,9 +160,19 @@ async fn dispatch_ui_action(\n return js_clear_errors(&form_error_target(error_target.as_ref())).into_response();\n }\n match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await {\n- Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(),\n- Ok(_) => form_js_error(error_target.as_ref(), \"unexpected response\", \"Check did not return CheckOk.\").into_response(),\n- Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ Ok(RpcResult::CheckOk { .. }) => {\n+ js_clear_errors(&form_error_target(error_target.as_ref())).into_response()\n+ }\n+ Ok(_) => form_js_error(\n+ error_target.as_ref(),\n+ \"unexpected response\",\n+ \"Check did not return CheckOk.\",\n+ )\n+ .into_response(),\n+ Err((msg, hint)) => {\n+ form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\"))\n+ .into_response()\n+ }\n }\n }\n HtmlUiAction::VoteComparePost {\n@@ -167,7 +187,11 @@ async fn dispatch_ui_action(\n form_action,\n } => {\n if form_action != \"/ui\" {\n- return (StatusCode::BAD_REQUEST, \"invalid vote_compare_post form_action\").into_response();\n+ return (\n+ StatusCode::BAD_REQUEST,\n+ \"invalid vote_compare_post form_action\",\n+ )\n+ .into_response();\n }\n let Some(session) = session else {\n return js_redirect(\"/login\").into_response();\n@@ -195,23 +219,15 @@ async fn dispatch_ui_action(\n let left_id = match crate::path_types::ItemId::parse(left_item.trim()) {\n Some(i) => i.normalized_storage(),\n None => {\n- return form_js_error(\n- err_tgt.as_ref(),\n- \"bad item\",\n- \"Invalid left item path.\",\n- )\n- .into_response();\n+ return form_js_error(err_tgt.as_ref(), \"bad item\", \"Invalid left item path.\")\n+ .into_response();\n }\n };\n let right_id = match crate::path_types::ItemId::parse(right_item.trim()) {\n Some(i) => i.normalized_storage(),\n None => {\n- return form_js_error(\n- err_tgt.as_ref(),\n- \"bad item\",\n- \"Invalid right item path.\",\n- )\n- .into_response();\n+ return form_js_error(err_tgt.as_ref(), \"bad item\", \"Invalid right item path.\")\n+ .into_response();\n }\n };\n let mut rl = ratio_left.trim().parse::().unwrap_or(0).max(0);\n@@ -231,7 +247,15 @@ async fn dispatch_ui_action(\n right_id.as_str()\n );\n \n- match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {\n+ match rpc_post_with_bearer(\n+ state,\n+ &session.bearer,\n+ room.clone(),\n+ thread_tag.clone(),\n+ text,\n+ )\n+ .await\n+ {\n Ok(RpcResult::PostOk {\n post_id,\n post_index,\n@@ -277,7 +301,10 @@ async fn dispatch_ui_action(\n \"Post did not return PostOk.\",\n )\n .into_response(),\n- Err((msg, hint)) => form_js_error(err_tgt.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ Err((msg, hint)) => {\n+ form_js_error(err_tgt.as_ref(), &msg, hint.as_deref().unwrap_or(\"\"))\n+ .into_response()\n+ }\n }\n }\n HtmlUiAction::SetGardenPin {\n@@ -288,7 +315,11 @@ async fn dispatch_ui_action(\n form_action,\n } => {\n if form_action != \"/ui\" {\n- return (StatusCode::BAD_REQUEST, \"invalid set_garden_pin form_action\").into_response();\n+ return (\n+ StatusCode::BAD_REQUEST,\n+ \"invalid set_garden_pin form_action\",\n+ )\n+ .into_response();\n }\n let next_path = sanitize_garden_pin_next(&next);\n use crate::html::{encode_pin_cookie_value, GARDEN_PIN_COOKIE};\n@@ -301,7 +332,11 @@ async fn dispatch_ui_action(\n if room.is_empty() {\n return (StatusCode::BAD_REQUEST, \"missing room\").into_response();\n }\n- let Some(raw) = item_storage.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) else {\n+ let Some(raw) = item_storage\n+ .as_ref()\n+ .map(|s| s.trim().to_string())\n+ .filter(|s| !s.is_empty())\n+ else {\n return (StatusCode::BAD_REQUEST, \"missing item\").into_response();\n };\n let Some(item) = ItemId::parse(&raw) else {\n@@ -309,16 +344,65 @@ async fn dispatch_ui_action(\n };\n let item = item.normalized_storage();\n let val = encode_pin_cookie_value(&room, item.as_str());\n- let cookie = format!(\"{GARDEN_PIN_COOKIE}={val}; Path=/; SameSite=Lax; Max-Age=7776000\");\n+ let cookie =\n+ format!(\"{GARDEN_PIN_COOKIE}={val}; Path=/; SameSite=Lax; Max-Age=7776000\");\n redirect_with_pin_cookie(&cookie, &next_path)\n }\n+ HtmlUiAction::ResolveExternal {\n+ room_wire,\n+ item_storage,\n+ mode,\n+ next,\n+ form_action,\n+ } => {\n+ if form_action != \"/ui\" {\n+ return (\n+ StatusCode::BAD_REQUEST,\n+ \"invalid resolve_external form_action\",\n+ )\n+ .into_response();\n+ }\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let room = room_wire.trim();\n+ if room.is_empty() {\n+ return ui_js_warn(\"missing room\").into_response();\n+ }\n+ let reduced = state.reduced.read().await;\n+ if matches!(scope_from_room_wire(room), ScopeId::Room(_)) {\n+ if !user_can_post_room(&reduced, room, &session.username) {\n+ drop(reduced);\n+ return ui_js_warn(\"forbidden\").into_response();\n+ }\n+ }\n+ drop(reduced);\n+\n+ let Some(item) = crate::path_types::ItemId::parse(item_storage.trim()) else {\n+ return ui_js_warn(\"bad item\").into_response();\n+ };\n+ let target = if mode.trim() == \"siblings\" {\n+ match item.parent() {\n+ Some(parent) => parent.normalized_storage(),\n+ None => return ui_js_warn(\"no parent to resolve siblings\").into_response(),\n+ }\n+ } else {\n+ item.normalized_storage()\n+ };\n+ match resolve_github_children(state, room, &target).await {\n+ Ok(_) => js_redirect(&sanitize_garden_pin_next(&next)).into_response(),\n+ Err(msg) => ui_js_warn(&msg).into_response(),\n+ }\n+ }\n HtmlUiAction::RedactPost { post_id } => {\n let Some(session) = session else {\n return js_redirect(\"/login\").into_response();\n };\n let h = headers_from_bearer(&session.bearer);\n match rpc_post_redact(state, &h, post_id).await {\n- Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),\n+ Ok(RpcResult::RedactPostOk {}) => {\n+ redact_success_response(state).await.into_response()\n+ }\n Ok(_) => (StatusCode::BAD_REQUEST, \"unexpected response\").into_response(),\n Err((msg, hint)) => {\n let detail = hint.as_deref().unwrap_or(\"\");\n@@ -326,7 +410,10 @@ async fn dispatch_ui_action(\n }\n }\n }\n- HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => {\n+ HtmlUiAction::SetRoomMembersExpanded {\n+ room_wire,\n+ expanded,\n+ } => {\n let room_wire = room_wire.trim().to_string();\n if room_wire.is_empty() {\n return ui_js_warn(\"missing room\").into_response();\n@@ -365,7 +452,10 @@ async fn dispatch_ui_action(\n }\n }\n }\n- HtmlUiAction::SetNewThreadComposeExpanded { room_wire, expanded } => {\n+ HtmlUiAction::SetNewThreadComposeExpanded {\n+ room_wire,\n+ expanded,\n+ } => {\n let room_wire = room_wire.trim().to_string();\n if room_wire.is_empty() {\n return ui_js_warn(\"missing room\").into_response();\n@@ -614,7 +704,9 @@ async fn post_success_response(\n };\n builder\n })\n- .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location));\n+ .if_current_path_not_matches(&thread_location, |builder| {\n+ builder.redirect(&thread_location)\n+ });\n \n builder.into_response()\n }\n@@ -625,4 +717,3 @@ async fn redact_success_response(state: &AppState) -> Response {\n .morph_selector(\"#thread-feed\", feed_markup)\n .into_response()\n }\n-\ndiff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs\nindex 700c3174b0e980d46ebf4aa94e8ffe2f8dedd723..3168956e7dce1cadf50c275904b550bf81500687 100644\n--- a/server/src/api/write_actor.rs\n+++ b/server/src/api/write_actor.rs\n@@ -54,7 +54,11 @@ fn content_for_room<'a>(reduced: &'a ReducerState, room: &str) -> &'a crate::red\n }\n \n async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str) {\n- let feed_id = if room_key == \"public\" { \"thread-feed\" } else { \"room-thread-feed\" };\n+ let feed_id = if room_key == \"public\" {\n+ \"thread-feed\"\n+ } else {\n+ \"room-thread-feed\"\n+ };\n let thread_url = if room_key == \"public\" {\n format!(\"/t/{thread_id}\")\n } else if let Some(seg) = room_route_segment(room_key) {\n@@ -86,7 +90,10 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str\n \"/\".to_string()\n }];\n path_prefixes.push(thread_url.clone());\n- let _ = state.js_tx.send(crate::state::JsSnippet { code: js, path_prefixes });\n+ let _ = state.js_tx.send(crate::state::JsSnippet {\n+ code: js,\n+ path_prefixes,\n+ });\n }\n \n fn compute_scope_rank_changes(\n@@ -104,7 +111,13 @@ fn compute_scope_rank_changes(\n for comp in &rankings.component_rankings {\n let total = comp.ranked.len();\n for (i, item) in comp.ranked.iter().enumerate() {\n- map.insert(item.item.clone(), Some(RankPosition { rank: i + 1, of: total }));\n+ map.insert(\n+ item.item.clone(),\n+ Some(RankPosition {\n+ rank: i + 1,\n+ of: total,\n+ }),\n+ );\n }\n }\n for item in &rankings.unranked_items {\n@@ -158,7 +171,11 @@ fn compute_scope_rank_changes(\n })\n }\n \n-async fn redeem_invite_grant(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {\n+async fn redeem_invite_grant(\n+ state: &AppState,\n+ invite_token: &str,\n+ grantee_username: &str,\n+) -> Result<(), String> {\n let now = now_ms();\n let ga = {\n let mut invites = state.invites.write().await;\n@@ -220,14 +237,14 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n let out = async {\n let mut reduced = state.reduced.write().await;\n \n- let principal =\n- verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n+ let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n \n let delegate: Option = match delegate_opt {\n None => None,\n Some(ref s) if s.trim().is_empty() => None,\n Some(s) => Some(\n- parse_agent(&s).map_err(|msg| (format!(\"invalid delegate format\"), Some(msg)))?,\n+ parse_agent(&s)\n+ .map_err(|msg| (format!(\"invalid delegate format\"), Some(msg)))?,\n ),\n };\n \n@@ -241,11 +258,12 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n ));\n }\n \n- let v = validate_ingest_document(&reduced, &text, &scope)\n- .map_err(|(st, m, h)| {\n+ let v = validate_ingest_document(&reduced, &text, &scope).map_err(\n+ |(st, m, h)| {\n let _ = st;\n (m, h)\n- })?;\n+ },\n+ )?;\n \n if is_private {\n use crate::events::ThreadCapability;\n@@ -276,7 +294,10 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n if let Some(ref d) = delegate {\n match reduced.agent_bindings.get(d) {\n Some(u) if u != &principal => {\n- return Err((\"delegate already bound to another user\".into(), None));\n+ return Err((\n+ \"delegate already bound to another user\".into(),\n+ None,\n+ ));\n }\n _ => {}\n }\n@@ -427,6 +448,80 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n let _ = reply.send(out);\n }\n \n+ WriteCmd::SystemIngest {\n+ room,\n+ thread_tag,\n+ text,\n+ principal,\n+ reply,\n+ } => {\n+ let out = async {\n+ let (room_key, thread_id) = normalize_room_and_thread(&room, &thread_tag);\n+ let scope = scope_from_room_wire(&room_key);\n+ let is_private = !matches!(scope, ScopeId::Public);\n+ let mut reduced = state.reduced.write().await;\n+ if is_private && !reduced.rooms.contains(&room_key) {\n+ return Err((\n+ \"unknown room\".into(),\n+ Some(format!(\"room `{}` does not exist\", room_key)),\n+ ));\n+ }\n+\n+ let v = validate_ingest_document(&reduced, &text, &scope).map_err(\n+ |(st, m, h)| {\n+ let _ = st;\n+ (m, h)\n+ },\n+ )?;\n+\n+ let new_post_id = uuid::Uuid::new_v4().to_string();\n+ let ingest_event = Event::Ingest(Ingest {\n+ ts: v.ts,\n+ id: new_post_id.clone(),\n+ raw: v.raw_text.clone(),\n+ principal: principal.clone(),\n+ delegate: None,\n+ room_id: room_key.clone(),\n+ thread_tag: thread_id.clone(),\n+ });\n+\n+ state\n+ .event_log\n+ .append(&ingest_event)\n+ .await\n+ .map_err(|e| (format!(\"{e}\"), None))?;\n+ reduced.apply_event(ingest_event);\n+ drop(reduced);\n+\n+ use crate::canonical_path::canonicalize_tag;\n+ let thread_tag_canon = canonicalize_tag(&thread_id);\n+ broadcast_web_refresh(&state, &room_key, &thread_tag_canon).await;\n+ let post_index = {\n+ let reduced = state.reduced.read().await;\n+ reduced.try_thread_post_index_chronological(\n+ &scope_from_room_wire(&room_key),\n+ &thread_tag_canon,\n+ &new_post_id,\n+ )\n+ };\n+\n+ Ok(RpcResult::PostOk {\n+ events_appended: 1,\n+ post_id: Some(new_post_id),\n+ post_index,\n+ ranking_changes: None,\n+ threads: vec![format!(\"#{}\", thread_id)],\n+ next: slug_types::NextMoves {\n+ pair: \"npx slugsocial public garden pair\".to_string(),\n+ rank: \"npx slugsocial public garden rank\".to_string(),\n+ web: ForumThreadUrl::from_room_tag(&room_key, &thread_id),\n+ },\n+ })\n+ }\n+ .await;\n+ let _ = reply.send(out);\n+ }\n+\n WriteCmd::Redact {\n post_id,\n bearer,\n@@ -434,8 +529,7 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n } => {\n let out = async {\n let mut reduced = state.reduced.write().await;\n- let principal =\n- verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n+ let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n let post_id = post_id.trim().to_string();\n let Some(ing) = reduced.ingests_by_id.get(&post_id).cloned() else {\n return Err((\"post not found\".into(), None));\n@@ -450,7 +544,11 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n let scope = scope_from_room_wire(&room_key);\n if matches!(scope, ScopeId::Room(_))\n && (!reduced.rooms.contains(&room_key)\n- || !reduced.user_has_cap(&room_key, &principal, crate::events::ThreadCapability::View))\n+ || !reduced.user_has_cap(\n+ &room_key,\n+ &principal,\n+ crate::events::ThreadCapability::View,\n+ ))\n {\n return Err((\"room not found\".into(), None));\n }\n@@ -484,14 +582,16 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n } => {\n let out = async {\n let mut reduced = state.reduced.write().await;\n- let principal =\n- verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n+ let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n let slug = slug.trim().to_lowercase();\n if slug.is_empty() || slug.len() > 64 {\n return Err((\"slug must be 1-64 characters\".into(), None));\n }\n if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {\n- return Err((\"slug must be lowercase alphanumeric with hyphens\".into(), None));\n+ return Err((\n+ \"slug must be lowercase alphanumeric with hyphens\".into(),\n+ None,\n+ ));\n }\n let short_id = loop {\n let id = gen_short_id();\n@@ -539,13 +639,16 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n let _ = reply.send(out);\n }\n \n- WriteCmd::RoomDelete { room, bearer, reply } => {\n+ WriteCmd::RoomDelete {\n+ room,\n+ bearer,\n+ reply,\n+ } => {\n let out = async {\n use crate::events::ThreadCapability;\n \n let mut reduced = state.reduced.write().await;\n- let principal =\n- verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n+ let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n let room = room.trim().to_string();\n if !reduced.rooms.contains(&room) {\n return Err((\"unknown room\".into(), None));\n@@ -589,15 +692,15 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n use crate::identity::parse_username;\n \n let mut reduced = state.reduced.write().await;\n- let principal =\n- verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n+ let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) {\n return Err((\"requires Manage capability\".into(), None));\n }\n if capabilities.is_empty() {\n return Err((\"capabilities must not be empty\".into(), None));\n }\n- let target = parse_username(&username).map_err(|msg| (\"invalid username\".into(), Some(msg)))?;\n+ let target = parse_username(&username)\n+ .map_err(|msg| (\"invalid username\".into(), Some(msg)))?;\n if !reduced.users_by_provider.values().any(|u| u == &target) {\n return Err((format!(\"user @{target} not found\"), None));\n }\n@@ -637,12 +740,12 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) {\n use crate::identity::parse_username;\n \n let mut reduced = state.reduced.write().await;\n- let principal =\n- verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n+ let principal = verify_token(&reduced, &bearer).map_err(|(_, m)| (m, None))?;\n if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) {\n return Err((\"requires Manage capability\".into(), None));\n }\n- let target = parse_username(&username).map_err(|msg| (\"invalid username\".into(), Some(msg)))?;\n+ let target = parse_username(&username)\n+ .map_err(|msg| (\"invalid username\".into(), Some(msg)))?;\n if !reduced.users_by_provider.values().any(|u| u == &target) {\n return Err((format!(\"user @{target} not found\"), None));\n }\ndiff --git a/server/src/dsl.rs b/server/src/dsl.rs\nindex def8b497c71567016a522648b9630d7038163e41..4203c2f59dd8825d7a91513c4023f3ccd37f1efc 100644\n--- a/server/src/dsl.rs\n+++ b/server/src/dsl.rs\n@@ -450,8 +450,8 @@ fn parse_block_prefixed_statement(\n }\n \n fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Result {\n- let (item1, j) =\n- parse_item_name_at(stripped, 0).ok_or_else(|| DslError::Parse(\"invalid item name\".to_string()))?;\n+ let (item1, j) = parse_item_name_at(stripped, 0)\n+ .ok_or_else(|| DslError::Parse(\"invalid item name\".to_string()))?;\n let i = skip_ws(stripped, j);\n \n if i >= stripped.len() {\n@@ -632,6 +632,19 @@ mod tests {\n );\n }\n \n+ #[test]\n+ fn parse_item_with_fenced_json_body_preserves_braces() {\n+ let input = \"~/item/in/url ```json\\n{\\\"test\\\": true}\\n```\";\n+ let doc = parse_full(input).unwrap();\n+ assert_eq!(\n+ doc.statements,\n+ vec![Stmt::Item {\n+ title: \"~/item/in/url\".to_string(),\n+ body: Some(\"```json\\n{\\\"test\\\": true}\\n```\".to_string()),\n+ }]\n+ );\n+ }\n+\n #[test]\n fn parse_vote_ratio_and_symbols() {\n let d1 = parse_full(\"{because}\\n~/a 3:1 ~/b\").unwrap();\ndiff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs\nindex 13f06b922a685f110d269f7397da807cf8600fe1..8481f1fbff1c4265da182a225f539f97d6829002 100644\n--- a/server/src/external_resolver.rs\n+++ b/server/src/external_resolver.rs\n@@ -1,6 +1,26 @@\n use async_trait::async_trait;\n+use serde_json::Value;\n+use tokio::sync::oneshot;\n \n-use crate::path_types::ItemId;\n+use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd};\n+\n+const GITHUB_SYSTEM_PRINCIPAL: &str = \"system:github-resolver\";\n+const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000;\n+\n+fn now_ms() -> i64 {\n+ use std::time::{SystemTime, UNIX_EPOCH};\n+ SystemTime::now()\n+ .duration_since(UNIX_EPOCH)\n+ .unwrap_or_default()\n+ .as_millis() as i64\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Eq)]\n+pub struct ResolvedChild {\n+ pub url: String,\n+ pub title: String,\n+ pub body: Option,\n+}\n \n #[async_trait]\n pub trait ExternalResolver: Send + Sync {\n@@ -14,7 +34,290 @@ pub trait ExternalResolver: Send + Sync {\n async fn fetch_body(&self, item: &ItemId) -> Result;\n }\n \n-/// Placeholder until domain-specific resolvers exist.\n+#[derive(Clone)]\n+pub struct GitHubResolver {\n+ client: reqwest::Client,\n+ api_base_url: String,\n+ token: Option,\n+}\n+\n+impl GitHubResolver {\n+ pub fn from_env() -> Self {\n+ let api_base_url = std::env::var(\"SLUG_GITHUB_API_BASE_URL\")\n+ .ok()\n+ .filter(|s| !s.trim().is_empty())\n+ .unwrap_or_else(|| \"https://api.github.com\".to_string());\n+ let token = std::env::var(\"SLUG_GITHUB_TOKEN\")\n+ .ok()\n+ .filter(|s| !s.trim().is_empty());\n+ Self {\n+ client: reqwest::Client::new(),\n+ api_base_url: api_base_url.trim_end_matches('/').to_string(),\n+ token,\n+ }\n+ }\n+\n+ pub fn can_resolve_children(&self, item: &ItemId) -> bool {\n+ github_segments(item).is_some()\n+ }\n+\n+ pub async fn list_children(&self, item: &ItemId) -> Result, String> {\n+ let segments = github_segments(item).ok_or_else(|| \"not a GitHub URL\".to_string())?;\n+ match segments.as_slice() {\n+ [] => Ok(vec![]),\n+ [owner] => self.list_repos(owner).await,\n+ [owner, repo] => Ok(github_repo_sections(owner, repo)),\n+ [owner, repo, section] if section == \"issues\" => self.list_issues(owner, repo).await,\n+ [owner, repo, section] if section == \"pulls\" => self.list_pulls(owner, repo).await,\n+ _ => Ok(vec![]),\n+ }\n+ }\n+\n+ async fn get_json(&self, path: &str) -> Result {\n+ let url = format!(\"{}/{}\", self.api_base_url, path.trim_start_matches('/'));\n+ let mut req = self\n+ .client\n+ .get(url)\n+ .header(reqwest::header::USER_AGENT, \"slugsocial-github-resolver\");\n+ if let Some(token) = &self.token {\n+ req = req.bearer_auth(token);\n+ }\n+ let resp = req\n+ .send()\n+ .await\n+ .map_err(|e| format!(\"GitHub request failed: {e}\"))?;\n+ let status = resp.status();\n+ if !status.is_success() {\n+ return Err(format!(\"GitHub request returned {status}\"));\n+ }\n+ resp.json::()\n+ .await\n+ .map_err(|e| format!(\"GitHub response JSON failed: {e}\"))\n+ }\n+\n+ async fn list_repos(&self, owner: &str) -> Result, String> {\n+ let value = self\n+ .get_json(&format!(\n+ \"/users/{owner}/repos?per_page=100&sort=updated&type=owner\"\n+ ))\n+ .await?;\n+ let arr = value\n+ .as_array()\n+ .ok_or_else(|| \"GitHub repos response was not an array\".to_string())?;\n+ let mut out = Vec::new();\n+ for repo in arr {\n+ let name = repo\n+ .get(\"name\")\n+ .and_then(|v| v.as_str())\n+ .unwrap_or_default();\n+ if name.is_empty() {\n+ continue;\n+ }\n+ let full_name = repo\n+ .get(\"full_name\")\n+ .and_then(|v| v.as_str())\n+ .map(|s| s.to_ascii_lowercase())\n+ .unwrap_or_else(|| format!(\"{owner}/{name}\").to_ascii_lowercase());\n+ out.push(ResolvedChild {\n+ url: format!(\"https://github.com/{full_name}\"),\n+ title: full_name.clone(),\n+ body: Some(github_json_body(repo)),\n+ });\n+ }\n+ out.sort_by(|a, b| a.url.cmp(&b.url));\n+ Ok(out)\n+ }\n+\n+ async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> {\n+ let value = self\n+ .get_json(&format!(\n+ \"/repos/{owner}/{repo}/issues?state=open&per_page=100\"\n+ ))\n+ .await?;\n+ let arr = value\n+ .as_array()\n+ .ok_or_else(|| \"GitHub issues response was not an array\".to_string())?;\n+ let mut out = Vec::new();\n+ for issue in arr {\n+ if issue.get(\"pull_request\").is_some() {\n+ continue;\n+ }\n+ let Some(number) = issue.get(\"number\").and_then(|v| v.as_i64()) else {\n+ continue;\n+ };\n+ let title = issue\n+ .get(\"title\")\n+ .and_then(|v| v.as_str())\n+ .unwrap_or(\"Untitled issue\");\n+ out.push(ResolvedChild {\n+ url: format!(\"https://github.com/{owner}/{repo}/issues/{number}\"),\n+ title: format!(\"#{number} {title}\"),\n+ body: Some(github_json_body(issue)),\n+ });\n+ }\n+ out.sort_by(|a, b| a.url.cmp(&b.url));\n+ Ok(out)\n+ }\n+\n+ async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> {\n+ let value = self\n+ .get_json(&format!(\n+ \"/repos/{owner}/{repo}/pulls?state=open&per_page=100\"\n+ ))\n+ .await?;\n+ let arr = value\n+ .as_array()\n+ .ok_or_else(|| \"GitHub pulls response was not an array\".to_string())?;\n+ let mut out = Vec::new();\n+ for pull in arr {\n+ let Some(number) = pull.get(\"number\").and_then(|v| v.as_i64()) else {\n+ continue;\n+ };\n+ let title = pull\n+ .get(\"title\")\n+ .and_then(|v| v.as_str())\n+ .unwrap_or(\"Untitled pull request\");\n+ out.push(ResolvedChild {\n+ url: format!(\"https://github.com/{owner}/{repo}/pulls/{number}\"),\n+ title: format!(\"#{number} {title}\"),\n+ body: Some(github_json_body(pull)),\n+ });\n+ }\n+ out.sort_by(|a, b| a.url.cmp(&b.url));\n+ Ok(out)\n+ }\n+}\n+\n+fn github_segments(item: &ItemId) -> Option> {\n+ let url = url::Url::parse(item.as_str()).ok()?;\n+ if url.host_str()?.eq_ignore_ascii_case(\"github.com\") {\n+ Some(\n+ url.path_segments()\n+ .map(|segments| {\n+ segments\n+ .filter(|s| !s.is_empty())\n+ .map(|s| s.to_ascii_lowercase())\n+ .collect::>()\n+ })\n+ .unwrap_or_default(),\n+ )\n+ } else {\n+ None\n+ }\n+}\n+\n+fn github_repo_sections(owner: &str, repo: &str) -> Vec {\n+ [\n+ (\"issues\", \"GitHub issues for this repository.\"),\n+ (\"pulls\", \"GitHub pull requests for this repository.\"),\n+ (\"commits\", \"GitHub commits for this repository.\"),\n+ (\"releases\", \"GitHub releases for this repository.\"),\n+ ]\n+ .into_iter()\n+ .map(|(section, body)| ResolvedChild {\n+ url: format!(\"https://github.com/{owner}/{repo}/{section}\"),\n+ title: section.to_string(),\n+ body: Some(body.to_string()),\n+ })\n+ .collect()\n+}\n+\n+fn resolver_thread_tag(item: &ItemId) -> String {\n+ let tail = item\n+ .display_path()\n+ .trim_start_matches(\"-/\")\n+ .replace('/', \":\")\n+ .replace('?', \":\");\n+ format!(\"import:{tail}\")\n+}\n+\n+fn sanitize_body(s: &str) -> String {\n+ s.replace('{', \"(\")\n+ .replace('}', \")\")\n+ .replace(\"```\", \"` ` `\")\n+ .chars()\n+ .take(4_000)\n+ .collect()\n+}\n+\n+fn github_json_body(value: &Value) -> String {\n+ let json = serde_json::to_string_pretty(value)\n+ .unwrap_or_else(|_| value.to_string())\n+ .replace(\"```\", \"` ` `\");\n+ format!(\"```json\\n{json}\\n```\")\n+}\n+\n+fn children_to_dsl(children: &[ResolvedChild]) -> String {\n+ let mut out = String::new();\n+ for child in children {\n+ let body = child\n+ .body\n+ .as_deref()\n+ .filter(|s| !s.trim().is_empty())\n+ .unwrap_or(child.title.as_str());\n+ if body.trim_start().starts_with(\"```\") {\n+ out.push_str(&format!(\"{} {}\\n\\n\", child.url, body.trim()));\n+ } else {\n+ out.push_str(&format!(\n+ \"{} {{\\n{}\\n}}\\n\\n\",\n+ child.url,\n+ sanitize_body(body)\n+ ));\n+ }\n+ }\n+ out\n+}\n+\n+pub async fn resolve_github_children(\n+ state: &AppState,\n+ room: &str,\n+ item: &ItemId,\n+) -> Result {\n+ if !state.github_resolver.can_resolve_children(item) {\n+ return Err(\"no GitHub resolver for this item\".to_string());\n+ }\n+\n+ let key = format!(\"github:{}:{}\", room.trim(), item.as_str());\n+ let now = now_ms();\n+ {\n+ let mut runs = state.resolver_runs.write().await;\n+ if let Some(last) = runs.get(&key) {\n+ let remaining = GITHUB_RESOLVER_COOLDOWN_MS - (now - *last);\n+ if remaining > 0 {\n+ return Err(format!(\n+ \"GitHub resolver cooldown: try again in {}s\",\n+ (remaining + 999) / 1000\n+ ));\n+ }\n+ }\n+ runs.insert(key, now);\n+ }\n+\n+ let children = state.github_resolver.list_children(item).await?;\n+ if children.is_empty() {\n+ return Ok(0);\n+ }\n+ let text = children_to_dsl(&children);\n+ let thread_tag = resolver_thread_tag(item);\n+ let (tx, rx) = oneshot::channel();\n+ state\n+ .write_tx\n+ .send(WriteCmd::SystemIngest {\n+ room: room.to_string(),\n+ thread_tag,\n+ text,\n+ principal: GITHUB_SYSTEM_PRINCIPAL.to_string(),\n+ reply: tx,\n+ })\n+ .await\n+ .map_err(|_| \"writer unavailable\".to_string())?;\n+ rx.await\n+ .map_err(|_| \"writer dropped\".to_string())?\n+ .map_err(|(msg, hint)| hint.map_or(msg.clone(), |h| format!(\"{msg}: {h}\")))?;\n+ Ok(children.len())\n+}\n+\n+/// Placeholder until other domain-specific resolvers exist.\n pub struct DefaultExternalResolver;\n \n #[async_trait]\n@@ -31,3 +334,51 @@ impl ExternalResolver for DefaultExternalResolver {\n Err(\"external fetch not implemented\".to_string())\n }\n }\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn github_segments_parse_normalized_url() {\n+ let item = ItemId::parse(\"https://github.com/Sortersocial/Slug/issues\").unwrap();\n+ assert_eq!(\n+ github_segments(&item),\n+ Some(vec![\n+ \"sortersocial\".to_string(),\n+ \"slug\".to_string(),\n+ \"issues\".to_string()\n+ ])\n+ );\n+ }\n+\n+ #[test]\n+ fn repo_sections_are_direct_children() {\n+ let sections = github_repo_sections(\"sortersocial\", \"slug\");\n+ let urls: Vec = sections.into_iter().map(|c| c.url).collect();\n+ assert!(urls.contains(&\"https://github.com/sortersocial/slug/issues\".to_string()));\n+ assert!(urls.contains(&\"https://github.com/sortersocial/slug/pulls\".to_string()));\n+ }\n+\n+ #[test]\n+ fn children_to_dsl_contains_item_bodies() {\n+ let dsl = children_to_dsl(&[ResolvedChild {\n+ url: \"https://github.com/o/r/issues/1\".into(),\n+ title: \"#1 title\".into(),\n+ body: Some(\"body with {braces}\".into()),\n+ }]);\n+ assert!(dsl.contains(\"https://github.com/o/r/issues/1\"));\n+ assert!(dsl.contains(\"body with (braces)\"));\n+ }\n+\n+ #[test]\n+ fn children_to_dsl_preserves_fenced_json_bodies() {\n+ let dsl = children_to_dsl(&[ResolvedChild {\n+ url: \"https://github.com/o/r/issues/1\".into(),\n+ title: \"#1 title\".into(),\n+ body: Some(\"```json\\n{\\\"test\\\": true}\\n```\".into()),\n+ }]);\n+ assert!(dsl.contains(\"https://github.com/o/r/issues/1 ```json\"));\n+ assert!(dsl.contains(\"{\\\"test\\\": true}\"));\n+ }\n+}\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex 4c03640334a524babe39c02289989739b1ad0ae9..b28271e0316738eae245855b24aeb76486860554 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -9,18 +9,21 @@ use serde::Deserialize;\n use serde_json::json;\n use std::collections::HashSet;\n \n-use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE};\n+use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE, Engine as _};\n \n use crate::{\n api::optional_principal,\n canonical_path::{canonicalize_item, canonicalize_tag},\n- middleware::canonical_view_url,\n events::ThreadCapability,\n form_template::template_json_compact,\n- html::{JsBuilder, ui_action::UI_RPC_FIELD, user_can_post_room},\n+ html::{ui_action::UI_RPC_FIELD, user_can_post_room, HtmlUiAction, JsBuilder},\n+ middleware::canonical_view_url,\n path_types::ItemId,\n reducer::{ContentState, ReducerState, ScopeId},\n- scope_rank::{ChildrenRankings, build_children_rankings},\n+ scope_rank::{\n+ build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive,\n+ ChildrenRankings,\n+ },\n state::AppState,\n timeago,\n };\n@@ -128,7 +131,11 @@ fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 {\n let l = ratio_left.max(0) as f64;\n let r = ratio_right.max(0) as f64;\n let sum = l + r;\n- if sum <= 0.0 { 0.5 } else { l / sum }\n+ if sum <= 0.0 {\n+ 0.5\n+ } else {\n+ l / sum\n+ }\n }\n \n /// Stronger preference for **`page_left` first**; ties **newer first**.\n@@ -864,6 +871,7 @@ struct ItemPageViewModel {\n /// False at the tilde ontology root (`~/`): sibling-rank footnote does not apply.\n item_has_parent: bool,\n child_rankings: ChildrenRankings,\n+ child_depth: usize,\n rank_history: Vec,\n /// Forum threads that mention or vote on this item.\n threads: Vec,\n@@ -1018,13 +1026,20 @@ fn build_item_page_view_model(\n reduced: &crate::reducer::ReducerState,\n scope: &ScopeId,\n item: &str,\n+ child_depth: usize,\n ) -> ItemPageViewModel {\n let content = content_for_garden_view(reduced, scope);\n let item_key = ItemId::parse(item)\n .unwrap_or_else(|| ItemId::parse(\"~/\").unwrap())\n .normalized_storage();\n let item_has_parent = item_key.parent().is_some();\n- let child_rankings = build_children_rankings(content, &item_key);\n+ let child_depth = child_depth.clamp(1, 5);\n+ let child_rankings = if child_depth > 1 {\n+ let items = resolve_scope_recursive(content, &[item_key.as_str().to_string()], child_depth);\n+ build_rankings_for_item_set(content, &items)\n+ } else {\n+ build_children_rankings(content, &item_key)\n+ };\n let sibling_nav = build_sibling_nav(reduced, scope, &item_key);\n \n let rank_history = build_rank_history(reduced, scope, item_key.as_str());\n@@ -1046,11 +1061,111 @@ fn build_item_page_view_model(\n sibling_nav,\n item_has_parent,\n child_rankings,\n+ child_depth,\n rank_history,\n threads,\n }\n }\n \n+fn child_depth_from_uri(uri: &Uri) -> usize {\n+ uri.query()\n+ .into_iter()\n+ .flat_map(|q| q.split('&'))\n+ .filter_map(|pair| pair.split_once('='))\n+ .find_map(|(k, v)| {\n+ if k == \"depth\" {\n+ v.parse::().ok()\n+ } else {\n+ None\n+ }\n+ })\n+ .unwrap_or(1)\n+ .clamp(1, 5)\n+}\n+\n+fn external_source_href(item: &str) -> String {\n+ let Ok(mut url) = url::Url::parse(item) else {\n+ return item.to_string();\n+ };\n+ let is_youtube = url\n+ .host_str()\n+ .map(|h| h.eq_ignore_ascii_case(\"www.youtube.com\"))\n+ .unwrap_or(false);\n+ if is_youtube {\n+ let segments: Vec = url\n+ .path_segments()\n+ .map(|s| s.map(|seg| seg.to_string()).collect())\n+ .unwrap_or_default();\n+ if segments.len() == 3 && segments[0] == \"watch\" && segments[1] == \"v\" {\n+ let id = segments[2].clone();\n+ url.set_path(\"/watch\");\n+ url.set_query(None);\n+ url.query_pairs_mut().append_pair(\"v\", &id);\n+ return url.to_string();\n+ }\n+ }\n+ item.to_string()\n+}\n+\n+fn external_frame_allowed(item: &str) -> bool {\n+ let Ok(url) = url::Url::parse(item) else {\n+ return false;\n+ };\n+ let host = url.host_str().unwrap_or_default();\n+ !matches!(host, \"github.com\" | \"www.github.com\")\n+}\n+\n+fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str) -> Option {\n+ let item_id = ItemId::parse(item)?.normalized_storage();\n+ let url = url::Url::parse(item_id.as_str()).ok()?;\n+ if !url\n+ .host_str()\n+ .map(|h| h.eq_ignore_ascii_case(\"github.com\"))\n+ .unwrap_or(false)\n+ {\n+ return None;\n+ }\n+ let children_rpc = template_json_compact(&HtmlUiAction::ResolveExternal {\n+ room_wire: nav.room_wire.clone(),\n+ item_storage: item_id.as_str().to_string(),\n+ mode: \"children\".to_string(),\n+ next: next.to_string(),\n+ form_action: \"/ui\".to_string(),\n+ })\n+ .ok()?;\n+ let siblings_rpc = item_id.parent().and_then(|_| {\n+ template_json_compact(&HtmlUiAction::ResolveExternal {\n+ room_wire: nav.room_wire.clone(),\n+ item_storage: item_id.as_str().to_string(),\n+ mode: \"siblings\".to_string(),\n+ next: next.to_string(),\n+ form_action: \"/ui\".to_string(),\n+ })\n+ .ok()\n+ });\n+\n+ Some(html! {\n+ section id=\"external-resolver-panel\" class=\"ont-tab-panel ont-external-resolver\" {\n+ h3 { \"GitHub resolver\" }\n+ p class=\"muted\" {\n+ \"Import GitHub neighbors on demand. Results are saved as system ingests.\"\n+ }\n+ div class=\"resolver-actions\" {\n+ form method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(children_rpc);\n+ button type=\"submit\" data-testid=\"github-resolve-children\" { \"Load children from GitHub\" }\n+ }\n+ @if let Some(rpc) = siblings_rpc {\n+ form method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc);\n+ button type=\"submit\" data-testid=\"github-resolve-siblings\" { \"Load siblings from GitHub\" }\n+ }\n+ }\n+ }\n+ }\n+ })\n+}\n+\n async fn render_scope_view(\n state: AppState,\n browse: GardenBrowsePath,\n@@ -1061,11 +1176,14 @@ async fn render_scope_view(\n let scope = nav.scope();\n let pin_ref = pinned_item_from_jar(&jar);\n let reduced = state.reduced.read().await;\n- let model = build_item_page_view_model(&reduced, &scope, browse.item());\n+ let child_depth = child_depth_from_uri(&uri);\n+ let model = build_item_page_view_model(&reduced, &scope, browse.item(), child_depth);\n let scope_content = content_for_garden_view(&reduced, &scope);\n let thread_href = |tag: &str| nav.thread_url(tag);\n let external_empty_body = browse.is_external() && model.body.is_none();\n let cli_path_arg = item_display_path(&model.item);\n+ let external_href = external_source_href(&model.item);\n+ let external_can_embed = external_frame_allowed(&model.item);\n let (garden_room, garden_prefix) = garden_layout_meta(&nav);\n let next_for_pin = uri\n .path_and_query()\n@@ -1104,7 +1222,24 @@ async fn render_scope_view(\n div class=\"ont-item-content ont-external-empty\" {\n p { \"This is an external scope.\" }\n p class=\"muted\" {\n- button type=\"button\" disabled { \"Kick off an Agent Run to import and rank items\" }\n+ \"This scope has no imported body yet. Open the source page directly.\"\n+ }\n+ p {\n+ a href=(external_href.as_str()) target=\"_blank\" rel=\"noopener noreferrer\" {\n+ \"Open external URL\"\n+ }\n+ }\n+ @if external_can_embed {\n+ p class=\"muted\" { \"Best-effort embedded preview:\" }\n+ iframe\n+ class=\"ont-external-frame\"\n+ src=(external_href.as_str())\n+ sandbox=\"\"\n+ loading=\"lazy\"\n+ referrerpolicy=\"no-referrer\"\n+ style=\"width:100%;height:360px;border:1px solid currentColor;\" {}\n+ } @else {\n+ p class=\"muted\" { \"Embedded preview is unavailable for this host.\" }\n }\n }\n } @else {\n@@ -1112,6 +1247,10 @@ async fn render_scope_view(\n }\n }\n \n+ @if let Some(markup) = github_resolver_controls(&model.item, &nav, &next_for_pin) {\n+ (markup)\n+ }\n+\n @if !model.rank_history.is_empty() {\n details class=\"ont-rank-history\" {\n summary {\n@@ -1182,7 +1321,13 @@ async fn render_scope_view(\n }\n \n section class=\"ont-tab-panel ont-tab-panel-children\" {\n- h3 { \"ranked child groups\" }\n+ h3 {\n+ \"ranked child groups\"\n+ @if model.child_depth > 1 {\n+ \" \"\n+ span class=\"muted\" { (format!(\"(depth {})\", model.child_depth)) }\n+ }\n+ }\n @if model.child_rankings.component_rankings.is_empty() {\n p class=\"muted\" { \"no voted pairs yet in this scope\" }\n } @else {\n@@ -1415,7 +1560,7 @@ async fn vote_compare_inner(\n \n #[cfg(test)]\n mod tests {\n- use super::build_item_page_view_model;\n+ use super::{build_item_page_view_model, external_source_href};\n use crate::{\n events::{Event, Ingest},\n reducer::{ReducerState, ScopeId},\n@@ -1516,7 +1661,7 @@ mod tests {\n ~/topic/b {beta}\\n\",\n );\n \n- let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic/a\");\n+ let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic/a\", 1);\n assert_eq!(model.body.as_deref(), Some(\"alpha\"));\n assert!(model.sibling_nav.is_some());\n assert!(model.child_rankings.component_rankings.is_empty());\n@@ -1536,7 +1681,7 @@ mod tests {\n {a beats b}\\n ~/topic/a 2:1 ~/topic/b\\n\",\n );\n \n- let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic/a\");\n+ let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic/a\", 1);\n let nav = model.sibling_nav.expect(\"expected sibling nav\");\n assert_eq!(nav.groups.len(), 2);\n assert_eq!(nav.groups[0].links.len(), 2);\n@@ -1558,7 +1703,7 @@ mod tests {\n {a beats b}\\n ~/topic/a 2:1 ~/topic/b\\n\",\n );\n \n- let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic/a\");\n+ let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic/a\", 1);\n let nav = model.sibling_nav.expect(\"expected sibling nav\");\n assert_eq!(nav.groups.len(), 3);\n assert_eq!(nav.groups[0].links.len(), 2);\n@@ -1582,7 +1727,7 @@ mod tests {\n ~/topic/kid1/leaf {leaf}\\n\",\n );\n \n- let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic\");\n+ let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic\", 1);\n assert_eq!(model.child_rankings.component_rankings.len(), 1);\n assert_eq!(model.child_rankings.component_rankings[0].pairs, 1);\n let names: Vec<&str> = model.child_rankings.component_rankings[0]\n@@ -1625,6 +1770,7 @@ mod tests {\n &reduced,\n &ScopeId::Room(\"9ab12cd/my-room\".to_string()),\n root.as_str(),\n+ 1,\n );\n assert!(!model.item_has_parent);\n assert_eq!(model.child_rankings.unranked_items.len(), 2);\n@@ -1655,6 +1801,7 @@ mod tests {\n &reduced,\n &ScopeId::Room(\"9ab12cd/my-room\".to_string()),\n root.as_str(),\n+ 1,\n );\n assert_eq!(model.child_rankings.component_rankings.len(), 1);\n assert_eq!(model.child_rankings.component_rankings[0].pairs, 1);\n@@ -1680,11 +1827,56 @@ mod tests {\n \"@00000000-0000-0000-0000-000000000000:test:local/test\\n~/x {x}\\n\",\n );\n let model =\n- build_item_page_view_model(&reduced, &ScopeId::Public, \"https://slug.social/~/\");\n+ build_item_page_view_model(&reduced, &ScopeId::Public, \"https://slug.social/~/\", 1);\n assert_eq!(model.child_rankings.unranked_items.len(), 1);\n assert_eq!(\n model.child_rankings.unranked_items[0].as_str(),\n \"https://slug.social/~/x\"\n );\n }\n+\n+ #[test]\n+ fn item_page_model_depth_includes_descendants() {\n+ let mut reduced = ReducerState::default();\n+ apply_ingest(\n+ &mut reduced,\n+ 1,\n+ \"@00000000-0000-0000-0000-000000000000:test:local/test\\n\\\n+ ~/topic {root}\\n\\\n+ ~/topic/a {alpha}\\n\\\n+ ~/topic/a/leaf {leaf}\\n\\\n+ ~/topic/b {beta}\\n\",\n+ );\n+\n+ let model = build_item_page_view_model(&reduced, &ScopeId::Public, \"~/topic\", 2);\n+ let items: std::collections::HashSet<&str> = model\n+ .child_rankings\n+ .unranked_items\n+ .iter()\n+ .map(|u| u.as_str())\n+ .collect();\n+ assert!(items.contains(\"https://slug.social/~/topic/a\"));\n+ assert!(items.contains(\"https://slug.social/~/topic/a/leaf\"));\n+ assert!(items.contains(\"https://slug.social/~/topic/b\"));\n+ }\n+\n+ #[test]\n+ fn external_source_href_maps_youtube_path_identity_back_to_watch_url() {\n+ assert_eq!(\n+ external_source_href(\"https://www.youtube.com/watch/v/dQw4w9WgXcQ\"),\n+ \"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"\n+ );\n+ assert_eq!(\n+ external_source_href(\"https://github.com/sortersocial/slug\"),\n+ \"https://github.com/sortersocial/slug\"\n+ );\n+ }\n+\n+ #[test]\n+ fn external_frame_allowed_skips_known_blocked_hosts() {\n+ assert!(!super::external_frame_allowed(\n+ \"https://github.com/sortersocial/slug\"\n+ ));\n+ assert!(super::external_frame_allowed(\"https://example.com/path\"));\n+ }\n }\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nindex 874fe729865c8b6179c94745221d8b79d625f642..8ab6f2924c3c66f826f38c487c48d6140eee4671 100644\n--- a/server/src/html/ui_action.rs\n+++ b/server/src/html/ui_action.rs\n@@ -66,10 +66,17 @@ pub enum HtmlUiAction {\n #[serde(default = \"default_ui_form_action\")]\n form_action: String,\n },\n- /// Author redacts own post via `POST /ui`.\n- RedactPost {\n- post_id: String,\n+ /// Resolve children or siblings for an external garden item.\n+ ResolveExternal {\n+ room_wire: String,\n+ item_storage: String,\n+ mode: String,\n+ next: String,\n+ #[serde(default = \"default_ui_form_action\")]\n+ form_action: String,\n },\n+ /// Author redacts own post via `POST /ui`.\n+ RedactPost { post_id: String },\n /// Morph `#room-members-section` — members list open or collapsed (server-rendered).\n SetRoomMembersExpanded {\n room_wire: String,\n@@ -77,9 +84,7 @@ pub enum HtmlUiAction {\n expanded: bool,\n },\n /// Delete the private room (Manage only); redirects to `/` on success.\n- DeleteRoom {\n- room: String,\n- },\n+ DeleteRoom { room: String },\n /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: \"public\"` for home).\n SetNewThreadComposeExpanded {\n room_wire: String,\n@@ -117,13 +122,14 @@ pub enum HtmlUiParseError {\n }\n \n /// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.\n-pub fn parse_html_ui_from_form(form: &HashMap) -> Result {\n- let template = form\n- .get(UI_RPC_FIELD)\n- .ok_or(HtmlUiParseError::MissingRpc)?;\n+pub fn parse_html_ui_from_form(\n+ form: &HashMap,\n+) -> Result {\n+ let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;\n let mut hole_map = form.clone();\n hole_map.remove(UI_RPC_FIELD);\n- let v: Value = fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;\n+ let v: Value =\n+ fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;\n serde_json::from_value(v).map_err(HtmlUiParseError::Action)\n }\n \ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex b902094653cf9319efd14e67b91436e269362441..84e94bbec144eae77de68482385941cd2c5845eb 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -3,11 +3,11 @@ pub mod paths;\n pub mod api;\n pub mod canonical_path;\n pub mod dsl;\n+pub mod event_log;\n+pub mod events;\n pub mod external_resolver;\n pub mod form_template;\n pub mod html;\n-pub mod event_log;\n-pub mod events;\n pub mod identity;\n pub mod middleware;\n pub mod path_types;\n@@ -50,16 +50,18 @@ pub fn create_app_state(cfg: AppConfig) -> AppState {\n js_tx,\n write_tx,\n views,\n+ resolver_runs: Arc::new(RwLock::new(HashMap::new())),\n+ github_resolver: Arc::new(crate::external_resolver::GitHubResolver::from_env()),\n };\n- tokio::spawn(crate::api::write_actor::writer_actor(write_rx, state.clone()));\n+ tokio::spawn(crate::api::write_actor::writer_actor(\n+ write_rx,\n+ state.clone(),\n+ ));\n state\n }\n \n /// Spawn the serialized writer. Used by integration tests after seeding reducer state in-process.\n-pub fn spawn_writer_actor_for_test(\n- state: AppState,\n- rx: tokio::sync::mpsc::Receiver,\n-) {\n+pub fn spawn_writer_actor_for_test(state: AppState, rx: tokio::sync::mpsc::Receiver) {\n tokio::spawn(crate::api::write_actor::writer_actor(rx, state));\n }\n \n@@ -89,10 +91,7 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/-\", get(crate::html::external_garden_index))\n .route(\"/-/*path\", get(crate::html::external_ontology_path))\n .route(\"/r/:room_key/~\", get(crate::html::room_garden_index))\n- .route(\n- \"/r/:room_key/~/*path\",\n- get(crate::html::room_ontology_path),\n- )\n+ .route(\"/r/:room_key/~/*path\", get(crate::html::room_ontology_path))\n .route(\n \"/r/:room_key/-\",\n get(crate::html::room_external_garden_index),\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 13831e868031edc19fda0f347376e0b17e8b99b7..8a80fed0a6bc7ae3516c76ab9d1e8592d7038ad1 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -3,7 +3,10 @@ use std::sync::Arc;\n \n use tokio::sync::{broadcast, mpsc, RwLock};\n \n-use crate::{event_log::EventLog, events::ThreadCapability, reducer::ReducerState, write_cmd::WriteCmd};\n+use crate::{\n+ event_log::EventLog, events::ThreadCapability, external_resolver::GitHubResolver,\n+ reducer::ReducerState, write_cmd::WriteCmd,\n+};\n \n /// Ephemeral invite link (24h TTL, in-memory only; not written to the event log).\n #[derive(Debug, Clone)]\n@@ -24,7 +27,7 @@ pub struct PendingSession {\n pub provider_id: Option,\n /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`].\n pub redeem_invite: Option,\n- pub complete: Option<(String /*username*/, String /*bearer*/ )>,\n+ pub complete: Option<(String /*username*/, String /*bearer*/)>,\n }\n \n /// An SSE event broadcast to all live stream subscribers when an ingest occurs.\n@@ -68,6 +71,9 @@ pub struct AppState {\n /// All durable writes and reducer mutations are serialized through this channel.\n pub write_tx: mpsc::Sender,\n pub views: crate::views::ViewStore,\n+ /// Ephemeral resolver cooldowns keyed by `room + item`: not persisted; durable results are JSONL ingests.\n+ pub resolver_runs: Arc>>,\n+ pub github_resolver: Arc,\n }\n \n impl AppState {\n@@ -94,7 +100,8 @@ impl AppState {\n js_tx,\n write_tx,\n views,\n+ resolver_runs: Arc::new(RwLock::new(HashMap::new())),\n+ github_resolver: Arc::new(GitHubResolver::from_env()),\n }\n }\n }\n-\ndiff --git a/server/src/write_cmd.rs b/server/src/write_cmd.rs\nindex 494aaeadf7bb919768788e101c3c1ffa12da8503..094a1b80b64caa327af523dc36c618ea04551cdc 100644\n--- a/server/src/write_cmd.rs\n+++ b/server/src/write_cmd.rs\n@@ -14,6 +14,13 @@ pub enum WriteCmd {\n bearer: String,\n reply: oneshot::Sender,\n },\n+ SystemIngest {\n+ room: String,\n+ thread_tag: String,\n+ text: String,\n+ principal: String,\n+ reply: oneshot::Sender,\n+ },\n Redact {\n post_id: String,\n bearer: String,\ndiff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..1d5b6f1c4575e67ec430e4ce7f02c1bca5a35775\n--- /dev/null\n+++ b/test/browser_github_resolver.clj\n@@ -0,0 +1,133 @@\n+(ns test.browser-github-resolver\n+ \"Playwright coverage for on-demand GitHub external resolver imports.\"\n+ (:require [babashka.fs :as fs]\n+ [cheshire.core :as json]\n+ [clojure.string :as str]\n+ [clojure.test :refer [deftest is]]\n+ [com.blockether.spel.core :as core]\n+ [com.blockether.spel.locator :as locator]\n+ [com.blockether.spel.page :as page]\n+ [org.httpkit.server :as http]\n+ [test.common :as common]\n+ [test.oauth :as oauth]))\n+\n+(defn- wait-for-text [pg selector expected timeout-ms]\n+ (let [deadline (+ (System/currentTimeMillis) timeout-ms)]\n+ (loop []\n+ (let [text (locator/text-content (page/locator pg selector))]\n+ (if (and (string? text) (str/includes? text expected))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false))))))\n+\n+(defn- start-mock-github [port]\n+ (let [!paths (atom [])\n+ handler (fn [req]\n+ (swap! !paths conj (:uri req))\n+ (case (:uri req)\n+ \"/users/octo/repos\"\n+ {:status 200\n+ :headers {\"Content-Type\" \"application/json\"}\n+ :body (json/generate-string\n+ [{:name \"hello\"\n+ :full_name \"octo/hello\"\n+ :description \"Hello repo\"}\n+ {:name \"other\"\n+ :full_name \"octo/other\"\n+ :description \"Other repo\"}])}\n+\n+ \"/repos/octo/hello/issues\"\n+ {:status 200\n+ :headers {\"Content-Type\" \"application/json\"}\n+ :body (json/generate-string\n+ [{:number 42\n+ :title \"Seeded issue\"\n+ :body \"Already pasted\"}\n+ {:number 43\n+ :title \"Imported sibling\"\n+ :body \"Loaded from mock GitHub\"}\n+ {:number 99\n+ :title \"PR should be filtered\"\n+ :pull_request {}}])}\n+\n+ {:status 404 :body \"not found\"}))\n+ stop-fn (http/run-server handler {:port port})]\n+ {:stop-fn stop-fn :paths !paths}))\n+\n+(defn github-resolver-flow! []\n+ (println \"\\n━━━ browser GitHub resolver: on-demand children and siblings ━━━\\n\")\n+\n+ (common/letlocals\n+ (bind build (common/run-cargo-build-release! [\"slugsocial-server\"]))\n+ (is (zero? (:exit build)) \"cargo build succeeds\")\n+ (bind server-bin \"target/release/slugsocial-server\")\n+\n+ (bind tmp-dir (str (fs/create-temp-dir {:prefix \"slug-browser-github-resolver-\"})))\n+ (bind slug-port (common/pick-port))\n+ (bind google-port (common/pick-port))\n+ (bind github-port (common/pick-port))\n+ (bind base-url (str \"http://127.0.0.1:\" slug-port))\n+ (bind google-url (str \"http://127.0.0.1:\" google-port))\n+ (bind github-url (str \"http://127.0.0.1:\" github-port))\n+\n+ (bind !server (atom nil))\n+ (bind !google (atom nil))\n+ (bind !github (atom nil))\n+ (bind server-env (assoc (common/slug-server-env tmp-dir base-url google-url slug-port)\n+ \"SLUG_GITHUB_API_BASE_URL\" github-url))\n+ (try\n+ (reset! !google (oauth/start-mock-google google-port\n+ :google-users [\"google-user-alice\"]))\n+ (reset! !github (start-mock-github github-port))\n+ (reset! !server (common/start-server server-bin server-env))\n+ (is (common/wait-for-server base-url 10000) \"server responds to /healthz\")\n+\n+ (let [alice-token (oauth/fetch-bearer-token! base-url :username \"alice\")\n+ seed-resp (oauth/http-post-json\n+ (str base-url \"/api/v0/rpc\")\n+ [{\"Post\" {\"room\" \"public\"\n+ \"thread_tag\" \"github-resolver-seed\"\n+ \"text\" \"https://github.com/octo/hello/issues/42 {Seed pasted issue}\"\n+ \"return_rank_diff\" false}}]\n+ :headers {\"Authorization\" (str \"Bearer \" alice-token)})\n+ seed-json (json/parse-string (:body seed-resp) false)]\n+ (is (true? (get-in seed-json [\"results\" 0 \"ok\"])) \"seed pasted GitHub issue\")\n+\n+ (core/with-playwright [pw]\n+ (core/with-browser [browser (core/launch-chromium pw {:headless true :channel \"chrome\"})]\n+ (core/with-context [ctx (core/new-context browser)]\n+ (core/with-page [pg (core/new-page-from-context ctx)]\n+ (page/navigate pg (str base-url \"/login\"))\n+ (is (wait-for-text pg \"body\" \"@alice\" 15000) \"alice session after login\")\n+\n+ (page/navigate pg (str base-url \"/-/github.com/octo\"))\n+ (is (wait-for-text pg \"#external-resolver-panel\" \"GitHub resolver\" 15000)\n+ \"user page shows resolver panel\")\n+ (locator/click (page/locator pg \"[data-testid=\\\"github-resolve-children\\\"]\"))\n+ (is (wait-for-text pg \"body\" \"-/github.com/octo/other\" 15000)\n+ \"user resolver imports repos\")\n+\n+ (page/navigate pg (str base-url \"/-/github.com/octo/hello\"))\n+ (locator/click (page/locator pg \"[data-testid=\\\"github-resolve-children\\\"]\"))\n+ (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/pulls\" 15000)\n+ \"repo resolver imports structural children\")\n+\n+ (page/navigate pg (str base-url \"/-/github.com/octo/hello/issues/42\"))\n+ (locator/click (page/locator pg \"[data-testid=\\\"github-resolve-siblings\\\"]\"))\n+ (page/navigate pg (str base-url \"/-/github.com/octo/hello/issues\"))\n+ (is (wait-for-text pg \"body\" \"-/github.com/octo/hello/issues/43\" 15000)\n+ \"issue sibling resolver imports issue siblings\")))))\n+\n+ (is (some #{\"/users/octo/repos\"} @(:paths @!github))\n+ \"mock GitHub saw user repos request\")\n+ (is (some #{\"/repos/octo/hello/issues\"} @(:paths @!github))\n+ \"mock GitHub saw repo issues request\"))\n+\n+ (finally\n+ (when-some [s @!server] (common/kill-server s))\n+ (when-some [g @!google] ((:stop-fn g)))\n+ (when-some [g @!github] ((:stop-fn g)))))))\n+\n+(deftest browser-github-resolver-test\n+ (github-resolver-flow!))\ndiff --git a/test/browser_ui_morph.clj b/test/browser_ui_morph.clj\nindex 1dad7eb142728e14f0fe1a77747c6f443192cd03..7740a44546de12731833f048a19c35af8c6f2d1e 100644\n--- a/test/browser_ui_morph.clj\n+++ b/test/browser_ui_morph.clj\n@@ -73,8 +73,8 @@\n (is (wait-for-text pg \"nav.breadcrumb\" \"repo\" 5000) \"breadcrumb includes path segment repo\")\n (is (wait-for-text pg \".ont-external-empty\" \"This is an external scope.\" 10000)\n \"external item shows empty-state copy\")\n- (is (wait-for-text pg \".ont-external-empty\" \"Kick off an Agent Run to import and rank items\" 5000)\n- \"external empty state shows disabled agent-run button label\")\n+ (is (wait-for-text pg \".ont-external-empty\" \"Open external URL\" 5000)\n+ \"external empty state links to source URL\")\n (page/navigate pg (str base-url \"/t/\" thread-tag))\n (is (wait-for-text pg \"#thread-feed-region\" \"[show full post]\" 15000)\n \"truncated card shows expand link\")\ndiff --git a/tests.edn b/tests.edn\nindex 103d38a1769a8f63754f08fb1e42a24c80ee37dc..5e2b0433a66c51806d08ef38d691259cfccd1fe0 100644\n--- a/tests.edn\n+++ b/tests.edn\n@@ -20,7 +20,8 @@\n \"^test\\\\.browser-public-garden$\"\n \"^test\\\\.browser-redact-thread-index$\"\n \"^test\\\\.browser-garden-pin$\"\n- \"^test\\\\.browser-vote-compare$\"]\n+ \"^test\\\\.browser-vote-compare$\"\n+ \"^test\\\\.browser-github-resolver$\"]\n :kaocha.filter/skip-meta [:skip]\n :parallel? false}]\n :plugins [:kaocha.plugin/junit-xml]\ndiff --git a/types/src/paths.rs b/types/src/paths.rs\nindex 8669f6872b9fa71d72a3f5c346945427bc00ac5a..791a44a0850b54b2e6baf16b47726bde507194c6 100644\n--- a/types/src/paths.rs\n+++ b/types/src/paths.rs\n@@ -361,13 +361,14 @@ mod tests {\n SLUG_TILDE_ONTOLOGY_ROOT.to_string()\n );\n assert_eq!(\n- ItemId::parse(\"https://slug.social/~/\")\n- .unwrap()\n- .as_str(),\n+ ItemId::parse(\"https://slug.social/~/\").unwrap().as_str(),\n SLUG_TILDE_ONTOLOGY_ROOT\n );\n let legacy = ItemId::opaque(\"https://slug.social/~/\".to_string());\n- assert_eq!(legacy.normalized_storage().as_str(), SLUG_TILDE_ONTOLOGY_ROOT);\n+ assert_eq!(\n+ legacy.normalized_storage().as_str(),\n+ SLUG_TILDE_ONTOLOGY_ROOT\n+ );\n }\n \n #[test]\n@@ -423,7 +424,8 @@ mod tests {\n #[test]\n fn garden_private_room_prefixes_ontology() {\n assert_eq!(\n- GardenItemUrl::from_storage_str(\"https://slug.social/~/topic/x\", \"9ab12cd/my-room\").as_str(),\n+ GardenItemUrl::from_storage_str(\"https://slug.social/~/topic/x\", \"9ab12cd/my-room\")\n+ .as_str(),\n \"https://slug.social/r/9ab12cdmy-room/~/topic/x\"\n );\n }\n@@ -465,11 +467,11 @@ mod tests {\n fn canonicalize_youtube_short_links() {\n assert_eq!(\n canonicalize_item(\"https://youtu.be/dQw4w9WgXcQ\"),\n- \"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"\n+ \"https://youtu.be/dQw4w9WgXcQ\"\n );\n assert_eq!(\n canonicalize_item(\"-/youtu.be/dQw4w9WgXcQ\"),\n- \"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"\n+ \"https://youtu.be/dQw4w9WgXcQ\"\n );\n }\n \n@@ -487,12 +489,29 @@ mod tests {\n }\n \n #[test]\n- fn item_id_parent_external_strips_last_segment() {\n- let c = ItemId::parse(\"https://spotify.com/track/1\").unwrap();\n+ fn canonicalize_external_url_strips_fragment_and_query_params() {\n+ assert_eq!(\n+ canonicalize_item(\"https://example.com/path?utm_source=newsletter&b=2#section\"),\n+ \"https://example.com/path\"\n+ );\n assert_eq!(\n- c.parent().unwrap().as_str(),\n- \"https://spotify.com/track\"\n+ canonicalize_item(\"-/example.com/path?FbClId=abc&a=1\"),\n+ \"https://example.com/path\"\n );\n+ }\n+\n+ #[test]\n+ fn canonicalize_github_external_url_normalizes_repo_identity() {\n+ assert_eq!(\n+ canonicalize_item(\"https://github.com/ORG/REPO.git?tab=issues&q=is%3Aopen\"),\n+ \"https://github.com/org/repo\"\n+ );\n+ }\n+\n+ #[test]\n+ fn item_id_parent_external_strips_last_segment() {\n+ let c = ItemId::parse(\"https://spotify.com/track/1\").unwrap();\n+ assert_eq!(c.parent().unwrap().as_str(), \"https://spotify.com/track\");\n assert_eq!(\n ItemId::parse(\"https://github.com/iss/1\")\n .unwrap()\n@@ -501,7 +520,10 @@ mod tests {\n .as_str(),\n \"https://github.com/iss\"\n );\n- assert!(ItemId::parse(\"https://github.com\").unwrap().parent().is_none());\n+ assert!(ItemId::parse(\"https://github.com\")\n+ .unwrap()\n+ .parent()\n+ .is_none());\n }\n \n #[test]\ndiff --git a/types/src/url_normalize.rs b/types/src/url_normalize.rs\nindex 999baa4f3367b632763b44ddc77b894451207c50..b655f4d834dd5a482aa1b5adad25b33461dbb76e 100644\n--- a/types/src/url_normalize.rs\n+++ b/types/src/url_normalize.rs\n@@ -1,7 +1,9 @@\n //! Normalization for external `http(s)://` item identity (not slug tilde ontology).\n //!\n //! Policy (intentional, extend here as new domains need treatment):\n-//! - Query pairs sorted lexicographically by **lowercased** key, then value.\n+//! - Query pairs are navigation/filter state by default and are stripped from identity.\n+//! - Known query-primary resources may canonicalize to path-based short links (YouTube `youtu.be/:id`).\n+//! - Fragments are stripped from identity.\n //! - YouTube family → stable `www.youtube.com` shapes where possible.\n \n use url::Url;\n@@ -23,8 +25,10 @@ pub fn normalize_http_identity_url(s: &str) -> Option {\n if !matches!(u.scheme(), \"http\" | \"https\") {\n return None;\n }\n+ u.set_fragment(None);\n rewrite_youtube(&mut u);\n- sort_query_pairs(&mut u);\n+ normalize_github(&mut u);\n+ u.set_query(None);\n Some(u.to_string())\n }\n \n@@ -45,23 +49,18 @@ fn rewrite_youtube(u: &mut Url) {\n \n match base.as_str() {\n \"youtu.be\" => {\n- let id = path.trim_start_matches('/').split('/').next().unwrap_or(\"\").to_string();\n+ let id = path\n+ .trim_start_matches('/')\n+ .split('/')\n+ .next()\n+ .unwrap_or(\"\")\n+ .to_string();\n if id.is_empty() {\n return;\n }\n- let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect();\n- let Ok(mut out) = Url::parse(&format!(\"https://www.youtube.com/watch?v={id}\")) else {\n+ let Ok(out) = Url::parse(&format!(\"https://youtu.be/{id}\")) else {\n return;\n };\n- {\n- let mut q = out.query_pairs_mut();\n- for (k, v) in saved {\n- if k.eq_ignore_ascii_case(\"v\") {\n- continue;\n- }\n- q.append_pair(&k, &v);\n- }\n- }\n *u = out;\n }\n \"youtube.com\" | \"m.youtube.com\" => {\n@@ -80,20 +79,9 @@ fn rewrite_youtube(u: &mut Url) {\n if id.is_empty() {\n return;\n }\n- let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect();\n- let Ok(mut out) = Url::parse(&format!(\"https://www.youtube.com/watch?v={id}\"))\n- else {\n+ let Ok(out) = Url::parse(&format!(\"https://youtu.be/{id}\")) else {\n return;\n };\n- {\n- let mut q = out.query_pairs_mut();\n- for (k, v) in saved {\n- if k.eq_ignore_ascii_case(\"v\") {\n- continue;\n- }\n- q.append_pair(&k, &v);\n- }\n- }\n *u = out;\n return;\n }\n@@ -109,25 +97,21 @@ fn rewrite_youtube(u: &mut Url) {\n if id.is_empty() {\n return;\n }\n- let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect();\n- let Ok(mut out) = Url::parse(&format!(\"https://www.youtube.com/watch?v={id}\"))\n- else {\n+ let Ok(out) = Url::parse(&format!(\"https://youtu.be/{id}\")) else {\n return;\n };\n- {\n- let mut q = out.query_pairs_mut();\n- for (k, v) in saved {\n- if k.eq_ignore_ascii_case(\"v\") {\n- continue;\n- }\n- q.append_pair(&k, &v);\n- }\n- }\n *u = out;\n return;\n }\n if path.starts_with(\"/watch\") {\n let _ = u.set_host(Some(\"www.youtube.com\"));\n+ if let Some((_, id)) = u.query_pairs().find(|(k, _)| k.eq_ignore_ascii_case(\"v\")) {\n+ if !id.is_empty() {\n+ if let Ok(out) = Url::parse(&format!(\"https://youtu.be/{id}\")) {\n+ *u = out;\n+ }\n+ }\n+ }\n return;\n }\n if path.starts_with(\"/shorts/\") {\n@@ -142,17 +126,9 @@ fn rewrite_youtube(u: &mut Url) {\n if id.is_empty() {\n return;\n }\n- let saved: Vec<(String, String)> = u.query_pairs().into_owned().collect();\n- let Ok(mut out) = Url::parse(&format!(\"https://www.youtube.com/shorts/{id}\"))\n- else {\n+ let Ok(out) = Url::parse(&format!(\"https://www.youtube.com/shorts/{id}\")) else {\n return;\n };\n- {\n- let mut q = out.query_pairs_mut();\n- for (k, v) in saved {\n- q.append_pair(&k, &v);\n- }\n- }\n *u = out;\n return;\n }\n@@ -163,25 +139,32 @@ fn rewrite_youtube(u: &mut Url) {\n }\n }\n \n-fn sort_query_pairs(u: &mut Url) {\n- let pairs: Vec<(String, String)> = u.query_pairs().into_owned().collect();\n- if pairs.is_empty() {\n- u.set_query(None);\n+fn normalize_github(u: &mut Url) {\n+ let Some(host_raw) = u.host_str() else {\n+ return;\n+ };\n+ if host_raw.to_ascii_lowercase() != \"github.com\" {\n return;\n }\n- let mut pairs = pairs;\n- pairs.sort_by(|a, b| {\n- a.0.to_ascii_lowercase()\n- .cmp(&b.0.to_ascii_lowercase())\n- .then_with(|| a.1.cmp(&b.1))\n- });\n- u.set_query(None);\n- {\n- let mut q = u.query_pairs_mut();\n- for (k, v) in pairs {\n- q.append_pair(&k, &v);\n+\n+ let Some(segments) = u.path_segments() else {\n+ return;\n+ };\n+ let mut segments: Vec = segments\n+ .filter(|s| !s.is_empty())\n+ .map(|s| s.to_ascii_lowercase())\n+ .collect();\n+ if let Some(repo) = segments.get_mut(1) {\n+ if let Some(stripped) = repo.strip_suffix(\".git\") {\n+ *repo = stripped.to_string();\n }\n }\n+\n+ if segments.is_empty() {\n+ u.set_path(\"/\");\n+ } else {\n+ u.set_path(&format!(\"/{}\", segments.join(\"/\")));\n+ }\n }\n \n #[cfg(test)]\n@@ -192,19 +175,19 @@ mod tests {\n fn youtube_youtu_be_to_watch() {\n assert_eq!(\n normalize_http_identity_url(\"https://youtu.be/dQw4w9WgXcQ\").as_deref(),\n- Some(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\")\n+ Some(\"https://youtu.be/dQw4w9WgXcQ\")\n );\n }\n \n #[test]\n- fn youtube_watch_query_sorted() {\n+ fn youtube_watch_v_query_becomes_path_child() {\n assert_eq!(\n normalize_http_identity_url(\"https://youtube.com/watch?v=Z&a=1&b=2\").as_deref(),\n- Some(\"https://www.youtube.com/watch?a=1&b=2&v=Z\")\n+ Some(\"https://youtu.be/Z\")\n );\n assert_eq!(\n normalize_http_identity_url(\"https://youtube.com/watch?b=2&a=1&v=Z\").as_deref(),\n- Some(\"https://www.youtube.com/watch?a=1&b=2&v=Z\")\n+ Some(\"https://youtu.be/Z\")\n );\n }\n \n@@ -212,7 +195,7 @@ mod tests {\n fn youtube_embed_to_watch() {\n assert_eq!(\n normalize_http_identity_url(\"https://www.youtube.com/embed/dQw4w9WgXcQ\").as_deref(),\n- Some(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\")\n+ Some(\"https://youtu.be/dQw4w9WgXcQ\")\n );\n }\n \n@@ -225,10 +208,56 @@ mod tests {\n }\n \n #[test]\n- fn arbitrary_query_sorted() {\n+ fn arbitrary_query_is_stripped() {\n assert_eq!(\n normalize_http_identity_url(\"https://example.com/x?z=1&a=2\").as_deref(),\n- Some(\"https://example.com/x?a=2&z=1\")\n+ Some(\"https://example.com/x\")\n+ );\n+ }\n+\n+ #[test]\n+ fn fragment_is_stripped_from_identity() {\n+ assert_eq!(\n+ normalize_http_identity_url(\"https://example.com/path?b=2#a-section\").as_deref(),\n+ Some(\"https://example.com/path\")\n+ );\n+ }\n+\n+ #[test]\n+ fn all_generic_query_params_are_stripped() {\n+ assert_eq!(\n+ normalize_http_identity_url(\n+ \"https://example.com/x?z=1&utm_source=newsletter&FbClId=abc&ref=share&a=2\"\n+ )\n+ .as_deref(),\n+ Some(\"https://example.com/x\")\n+ );\n+ }\n+\n+ #[test]\n+ fn stripping_tracking_params_removes_empty_query() {\n+ assert_eq!(\n+ normalize_http_identity_url(\"https://example.com/x?utm_medium=email&si=share\")\n+ .as_deref(),\n+ Some(\"https://example.com/x\")\n+ );\n+ }\n+\n+ #[test]\n+ fn youtube_identity_query_is_promoted_before_query_cleanup() {\n+ assert_eq!(\n+ normalize_http_identity_url(\"https://youtu.be/dQw4w9WgXcQ?si=share&v=ignored&t=12\")\n+ .as_deref(),\n+ Some(\"https://youtu.be/dQw4w9WgXcQ\")\n+ );\n+ }\n+\n+ #[test]\n+ fn github_repo_suffix_and_noise_query_are_normalized() {\n+ assert_eq!(\n+ normalize_http_identity_url(\"https://github.com/ORG/REPO.git?tab=readme&q=is%3Aopen\")\n+ .as_deref(),\n+ Some(\"https://github.com/org/repo\")\n );\n }\n }\n@@ -324,7 +353,10 @@ mod url_identity_tests {\n fn empty_path_vs_slash_only_path_may_differ() {\n let root = Url::parse(\"https://example.com\").unwrap();\n let slash = Url::parse(\"https://example.com/\").unwrap();\n- assert_eq!(root, slash, \"root and trailing-slash-only merge for this parser\");\n+ assert_eq!(\n+ root, slash,\n+ \"root and trailing-slash-only merge for this parser\"\n+ );\n }\n \n #[test]\n@@ -353,6 +385,9 @@ mod url_identity_tests {\n \n let plus = Url::parse(\"https://example.com/?q=a+b\").unwrap();\n let encoded = Url::parse(\"https://example.com/?q=a%20b\").unwrap();\n- assert_ne!(plus, encoded, \"space as + vs %20 — different keys unless normalized\");\n+ assert_ne!(\n+ plus, encoded,\n+ \"space as + vs %20 — different keys unless normalized\"\n+ );\n }\n }\n","role":"user"}],"model":"openai/gpt-5.2-chat"}