From ba6794c20b98753f1ff9d9bf93efb0f4f8d3e806 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 16:55:32 +0200 Subject: [PATCH 1/3] feat(hosting): add GitLab, Bitbucket Cloud and custom GitHub adapters --- Cargo.lock | 1 + README.md | 7 + ROADMAP.md | 10 + TASKS.md | 15 +- crates/strand-tauri/Cargo.toml | 1 + crates/strand-tauri/src/commands.rs | 9 + crates/strand-tauri/src/main.rs | 2 + crates/strand-tauri/src/pull_requests.rs | 291 ++-- .../strand-tauri/src/pull_requests/hosted.rs | 1327 +++++++++++++++++ .../src/pull_requests/transport.rs | 460 ++++++ docs/hosted-provider-contracts.md | 72 + docs/learnings.md | 11 + ui/src/App.tsx | 1 + ui/src/demo/dispatch.ts | 2 + ui/src/lib/hostingCapabilities.test.ts | 37 + ui/src/lib/pullRequests.ts | 4 + ui/src/lib/tauri.ts | 3 + ui/src/lib/types.ts | 5 +- ui/src/views/PullRequestCreateDialog.tsx | 3 +- ui/src/views/PullRequestMergeControl.tsx | 24 +- ui/src/views/PullRequests.tsx | 17 +- ui/src/views/settings/HostingSection.tsx | 4 +- .../views/settings/RemoteProviderSettings.tsx | 34 + website/docs/pull-requests.md | 49 +- website/docs/settings.md | 22 + 25 files changed, 2286 insertions(+), 125 deletions(-) create mode 100644 crates/strand-tauri/src/pull_requests/hosted.rs create mode 100644 crates/strand-tauri/src/pull_requests/transport.rs create mode 100644 docs/hosted-provider-contracts.md create mode 100644 ui/src/lib/hostingCapabilities.test.ts create mode 100644 ui/src/views/settings/RemoteProviderSettings.tsx diff --git a/Cargo.lock b/Cargo.lock index 4ea18990..0447559c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5944,6 +5944,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha1_smol", "sha2", "sqlx", "strand-azdo-protocol", diff --git a/README.md b/README.md index 40ac8df7..f37f300a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ the resolved app appearance automatically. ## Features +- **More hosting providers** — GitLab merge requests and Bitbucket Cloud pull + requests use the review workspace, with paged lists, comments, inline threads + and supported review decisions. GitHub Enterprise/custom hosts use their own + CLI authentication scope; select custom adapters in Settings → Hosting. + GitLab merges guard the reviewed head and follow project settings. Bitbucket + merge, GitLab request-changes and Bitbucket draft transitions remain + provider-site actions. Bitbucket Server is not supported. - **Responsive refreshes** — repository updates coalesce during bursts of agent edits, hidden diff panes load patches when opened, and Files reuses its inventory until paths or ignore rules change. Workspace scans run with diff --git a/ROADMAP.md b/ROADMAP.md index f048b0bf..e7220f04 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2188,6 +2188,8 @@ and Store certification remain external gates. - ◐ Hosted-review expansion — GitLab/Bitbucket adapters, deeper pagination, merge queue/auto-complete, and review-evolution comparisons build on the completed GitHub/Azure 1.0 workspace. + GitLab, Bitbucket Cloud and custom GitHub host adapters are complete; + the remaining expansion items stay open. **AI commit messages (2026-07-01):** Subscription-first suggestions prefer the staged diff, and fall back to all unstaged changes when no staged diff exists — @@ -2810,6 +2812,14 @@ GitHub/Azure review, Workbench and performance work retain their own status. --- +**Hosted adapters shipped (2026-09-06):** GitLab merge requests and Bitbucket +Cloud pull requests share the existing review workspace, with paged collections, +provider-specific inline coordinates, permission-gated controls and explicit +fallbacks for unsupported writes. Custom GitHub hosts use host-scoped CLI +authentication and per-remote adapter selection. GitHub.com and Azure routing +remain intact. Bitbucket merge stays on the provider because its API cannot +atomically guard the reviewed head. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 4c7910bf..d2b839c0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1918,11 +1918,18 @@ tree: watch the agent work, review fast, accept or reject safely. - ☑ Close/reopen the PR (`repo_pull_request_lifecycle`; GitHub `gh pr`, Azure Services `az repos pr update`, and Azure Server helper protocol v2 `Operation::SetStatus`; keyboard-operable confirmed overflow action). - - ☐ 1.1: GitLab merge-request adapter. - - ☐ 1.1: Bitbucket Cloud pull-request adapter; scope Bitbucket Server separately. - - ☐ **F11 / P2 — GitHub enterprise/custom-host adapter.** Model host/API/auth + - ☑ 1.1: GitLab merge-request adapter (`HostedRepo`, paged collections, + versioned inline coordinates, approvals, lifecycle and SHA-guarded merge). + - ☑ 1.1: Bitbucket Cloud pull-request adapter (`HostedRepo`, opaque pagination, + replies/ranges, approval/request-changes and capability-gated actions). + - ☐ Bitbucket Server adapter; Cloud merge remains a provider-site action + until an atomic expected-head guard is available. GitLab request-changes + and Bitbucket discussion resolution/draft transitions also remain + provider-site actions. + - ☑ **F11 / P2 — GitHub enterprise/custom-host adapter.** Model host/API/auth scope instead of hardcoding GitHub.com; keep the GitLab/Bitbucket adapter - rows above as the other F11 deliverables. + rows above as the other F11 deliverables (`GitHubContext`, host-scoped CLI + routing and per-remote adapter selection in Settings → Hosting). - ☐ 1.1: Direct OAuth + OS-keychain credentials if/when Strand stops delegating auth to provider CLIs (blocked on Platform → per-platform credential storage). diff --git a/crates/strand-tauri/Cargo.toml b/crates/strand-tauri/Cargo.toml index 0213b2dd..38707d08 100644 --- a/crates/strand-tauri/Cargo.toml +++ b/crates/strand-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] } # tauri-plugin-sql, so these direct deps add ~no build cost. sqlx = { version = "0.8", default-features = false, features = ["sqlite", "runtime-tokio"] } sha2 = "0.10" +sha1_smol = "1" base64 = "0.22" flate2 = "1" minisign-verify = "0.2.5" diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index e0ec2642..818cd028 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -2148,3 +2148,12 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } } + +#[tauri::command(async)] +pub async fn repo_hosting_providers(path: String) -> CmdResult> { + run_blocking("remote hosting providers", move || pull_requests::hosting_providers(&path).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn repo_set_hosting_provider(path: String, remote: String, provider: String) -> CmdResult<()> { + run_blocking("configure remote provider", move || pull_requests::set_hosting_provider(&path, &remote, &provider).map_err(|message| CmdError { message })).await +} diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 0b3fad89..239dc216 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -177,6 +177,8 @@ fn main() { commands::repo_refs, commands::azdo_helper_status, commands::hosting_connection_status, + commands::repo_hosting_providers, + commands::repo_set_hosting_provider, commands::azdo_helper_enable, commands::azdo_helper_disable, commands::azdo_helper_remove, diff --git a/crates/strand-tauri/src/pull_requests.rs b/crates/strand-tauri/src/pull_requests.rs index 9f08edc7..a122e24b 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -1,9 +1,12 @@ //! Pull-request host integration. //! -//! Authentication stays with the provider CLIs (`gh` and `az`): Strand never -//! reads or stores their tokens. The list call stays shallow; a second command +//! CLI authentication stays with `gh`, `glab` and `az`; Bitbucket API credentials +//! come from the system Git helper. The list call stays shallow; a second command //! loads nested metadata only for the selected pull request so provider query //! limits and large repositories remain predictable. +mod hosted; +pub(crate) mod transport; +use transport::{github_command, github_command_input, GitHubContext}; use std::{ collections::HashMap, @@ -138,6 +141,8 @@ type Result = std::result::Result; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum PullRequestProvider { + GitLab, + Bitbucket, GitHub, AzureDevOps, } @@ -267,8 +272,20 @@ struct AzureDiscussion { review_threads: Vec, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Default)] +pub struct PullRequestCapabilities { + pub can_comment: bool, + pub can_review: bool, + pub can_request_changes: bool, + pub can_close: bool, + pub can_reopen: bool, + pub merge_strategies: Vec, +} + +#[derive(Debug, Clone, Serialize, Default)] pub struct PullRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option, pub id: u64, pub title: String, pub state: String, @@ -372,7 +389,9 @@ pub enum PullRequestDiffSide { #[derive(Debug, Clone, PartialEq, Eq)] enum HostRepo { + Hosted(hosted::HostedRepo), GitHub { + host: String, owner: String, repo: String, }, @@ -392,7 +411,8 @@ enum HostRepo { pub fn list(path: &str) -> Result { let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => list_github(path, remote, owner, repo), + HostRepo::Hosted(host) => host.list(&host.client(path), remote, None), + HostRepo::GitHub { host, owner, repo } => list_github(&GitHubContext { path, host: &host }, remote, owner, repo), HostRepo::Azure { organization, project, @@ -410,7 +430,8 @@ pub fn list(path: &str) -> Result { pub fn for_branch(path: &str, branch: &str) -> Result> { let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => for_branch_github(path, remote, owner, repo, branch), + HostRepo::Hosted(host) => host.list(&host.client(path), remote, Some(branch)).map(|list| list.pull_requests.into_iter().next().map(|pull_request| PullRequestBranchMatch { repository: list.repository, pull_request })), + HostRepo::GitHub { host, owner, repo } => for_branch_github(&GitHubContext { path, host: &host }, remote, owner, repo, branch), HostRepo::Azure { organization, project, @@ -437,8 +458,8 @@ pub fn create( let (remote, host) = host_for_path(path)?; ensure_source_branch_on_remote(path, &remote, source_branch)?; match host { - HostRepo::GitHub { owner, repo } => create_github( - path, + HostRepo::Hosted(host) => host.create(&host.client(path), source_branch, target_branch, title, description, is_draft), + HostRepo::GitHub { host, owner, repo } => create_github(&GitHubContext { path, host: &host }, &owner, &repo, source_branch, @@ -516,7 +537,8 @@ fn ensure_source_branch_on_remote(path: &str, remote: &str, source_branch: &str) pub fn activity(path: &str, id: u64) -> Result { let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => activity_github(path, remote, owner, repo, id), + HostRepo::Hosted(host) => host.activity(&host.client(path), remote, id), + HostRepo::GitHub { host, owner, repo } => activity_github(&GitHubContext { path, host: &host }, remote, owner, repo, id), HostRepo::Azure { organization, project, @@ -534,7 +556,8 @@ pub fn activity(path: &str, id: u64) -> Result { pub fn detail(path: &str, id: u64) -> Result { let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => detail_github(path, owner, repo, id), + HostRepo::Hosted(host) => host.detail(&host.client(path), id), + HostRepo::GitHub { host, owner, repo } => detail_github(&GitHubContext { path, host: &host }, owner, repo, id), HostRepo::Azure { organization, project, @@ -552,7 +575,8 @@ pub fn detail(path: &str, id: u64) -> Result { pub fn diff(path: &str, id: u64) -> Result { let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => diff_github(path, owner, repo, id), + HostRepo::Hosted(host) => host.diff(&host.client(path), id), + HostRepo::GitHub { host, owner, repo } => diff_github(&GitHubContext { path, host: &host }, owner, repo, id), HostRepo::Azure { organization, project, @@ -571,7 +595,8 @@ pub fn add_comment(path: &str, id: u64, body: &str) -> Result<()> { validate_comment(body)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => add_comment_github(path, owner, repo, id, body), + HostRepo::Hosted(host) => host.add_comment(&host.client(path), id, body), + HostRepo::GitHub { host, owner, repo } => add_comment_github(&GitHubContext { path, host: &host }, owner, repo, id, body), HostRepo::Azure { organization, project, @@ -607,13 +632,13 @@ pub fn add_inline_comment( } let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - let current = detail_github(path, owner.clone(), repo.clone(), id)?; + HostRepo::Hosted(host) => host.inline(&host.client(path), id, &PullRequestPendingComment { path: file_path.into(), start_line, end_line, side, body: body.into() }, expected_head), + HostRepo::GitHub { host, owner, repo } => { + let current = detail_github(&GitHubContext { path, host: &host }, owner.clone(), repo.clone(), id)?; if current.source_commit != expected_head { return Err("The pull request changed while this comment was being written. Refresh Changes and select the lines again.".to_string()); } - add_inline_comment_github( - path, owner, repo, id, body, file_path, start_line, end_line, side, + add_inline_comment_github(&GitHubContext { path, host: &host }, owner, repo, id, body, file_path, start_line, end_line, side, expected_head, ) } @@ -635,7 +660,8 @@ pub fn reply_to_thread(path: &str, thread_id: &str, body: &str) -> Result reply_to_thread_github(path, thread_id, body), + HostRepo::Hosted(host) => host.reply(&host.client(path), thread_id, body), + HostRepo::GitHub { host, .. } => reply_to_thread_github(&GitHubContext { path, host: &host }, thread_id, body), HostRepo::Azure { organization, project, @@ -665,7 +691,8 @@ pub fn set_thread_resolved( validate_thread_id(thread_id)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { .. } => set_thread_resolved_github(path, thread_id, resolved), + HostRepo::Hosted(host) => host.resolve(&host.client(path), thread_id, resolved), + HostRepo::GitHub { host, .. } => set_thread_resolved_github(&GitHubContext { path, host: &host }, thread_id, resolved), HostRepo::Azure { organization, project, @@ -705,8 +732,8 @@ pub fn submit_review( validate_commit(expected_head)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => submit_review_github( - path, &owner, &repo, id, event, body, comments, expected_head, + HostRepo::Hosted(host) => host.review(&host.client(path), id, event, body, comments, expected_head), + HostRepo::GitHub { host, owner, repo } => submit_review_github(&GitHubContext { path, host: &host }, &owner, &repo, id, event, body, comments, expected_head, ), HostRepo::Azure { organization, project, repo } => submit_review_azure( path, &organization, &project, &repo, id, event, body, comments, expected_head, @@ -722,7 +749,8 @@ pub fn update_review(path: &str, _id: u64, review_id: &str, body: &str) -> Resul validate_comment(body)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { .. } => update_review_github(path, review_id, body), + HostRepo::Hosted(_) => Err("Edit review summaries on the provider website".into()), + HostRepo::GitHub { host, .. } => update_review_github(&GitHubContext { path, host: &host }, review_id, body), HostRepo::Azure { .. } | HostRepo::AzureServer { .. } => Err( "Azure DevOps votes do not have an editable review summary. Submit a new summary comment or change the vote instead." .into(), @@ -739,9 +767,10 @@ pub fn dismiss_review( validate_review_id(review_id)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { .. } => { + HostRepo::Hosted(_) => Err("Reset reviews on the provider website".into()), + HostRepo::GitHub { host, .. } => { validate_comment(message)?; - dismiss_review_github(path, review_id, message) + dismiss_review_github(&GitHubContext { path, host: &host }, review_id, message) } HostRepo::Azure { organization, .. } => { reset_review_azure(path, &organization, id, review_id) @@ -764,8 +793,9 @@ pub fn merge( validate_commit(expected_head)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - merge_github(path, &owner, &repo, id, strategy, expected_head) + HostRepo::Hosted(host) => host.merge(&host.client(path), id, strategy, expected_head), + HostRepo::GitHub { host, owner, repo } => { + merge_github(&GitHubContext { path, host: &host }, &owner, &repo, id, strategy, expected_head) } HostRepo::Azure { organization, @@ -792,7 +822,8 @@ pub fn merge( pub fn mark_ready(path: &str, id: u64) -> Result<()> { let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => mark_ready_github(path, &owner, &repo, id), + HostRepo::Hosted(host) => host.ready(&host.client(path), id), + HostRepo::GitHub { host, owner, repo } => mark_ready_github(&GitHubContext { path, host: &host }, &owner, &repo, id), HostRepo::Azure { organization, .. } => mark_ready_azure(path, &organization, id), HostRepo::AzureServer { profile_id, @@ -806,8 +837,9 @@ pub fn mark_ready(path: &str, id: u64) -> Result<()> { pub fn set_lifecycle(path: &str, id: u64, action: PullRequestLifecycleAction) -> Result<()> { let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - set_lifecycle_github(path, &owner, &repo, id, action) + HostRepo::Hosted(host) => host.lifecycle(&host.client(path), id, action), + HostRepo::GitHub { host, owner, repo } => { + set_lifecycle_github(&GitHubContext { path, host: &host }, &owner, &repo, id, action) } HostRepo::Azure { organization, .. } => { set_lifecycle_azure(path, &organization, id, action) @@ -825,8 +857,9 @@ pub fn update_branch(path: &str, id: u64, expected_head: &str) -> Result<()> { validate_commit(expected_head)?; let (_, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - update_branch_github(path, &owner, &repo, id, expected_head) + HostRepo::Hosted(_) => Err("Update this source branch in a local worktree".into()), + HostRepo::GitHub { host, owner, repo } => { + update_branch_github(&GitHubContext { path, host: &host }, &owner, &repo, id, expected_head) } HostRepo::Azure { .. } | HostRepo::AzureServer { .. } => Err( "Azure DevOps does not expose a safe update-source-branch pull-request operation. Open the branch in a worktree and update it locally." @@ -843,8 +876,9 @@ pub fn prepare_checkout( validate_commit(expected_head)?; let (remote, host) = host_for_path(path)?; match host { - HostRepo::GitHub { owner, repo } => { - prepare_checkout_github(path, &remote, &owner, &repo, id, expected_head) + HostRepo::Hosted(host) => host.checkout(&host.client(path), path, &remote, id, expected_head), + HostRepo::GitHub { host, owner, repo } => { + prepare_checkout_github(&GitHubContext { path, host: &host }, &remote, &owner, &repo, id, expected_head) } HostRepo::Azure { organization, .. } => { let value = azure_pr_value(path, &organization, id)?; @@ -881,7 +915,10 @@ fn host_for_path(path: &str) -> Result<(String, HostRepo)> { let mut supported = remotes .iter() .filter_map(|remote| { - let coordinates = parse_remote(&remote.1)?; + let coordinates = parse_remote(&remote.1).or_else(|| { + let configured = run_command(path, "git", &["config", "--get", &format!("remote.{}.strand-provider", remote.0)], &[]).ok().and_then(|v| String::from_utf8(v).ok()); + parse_hosted_remote(&remote.1, configured.as_deref().map(str::trim)) + })?; Some((remote.0.clone(), coordinates)) }) .collect::>(); @@ -906,19 +943,19 @@ fn host_for_path(path: &str) -> Result<(String, HostRepo)> { } supported.into_iter().next().ok_or_else(|| { - "No supported GitHub, Azure DevOps Services, or configured Azure DevOps Server remote was found for this repository".to_string() + "No supported hosting remote was found. Configure a custom GitHub/GitLab remote provider in Hosting settings.".to_string() }) } -fn list_github(cwd: &str, remote: String, owner: String, repo: String) -> Result { - let slug = format!("{owner}/{repo}"); +fn list_github(cwd: &GitHubContext<'_>, remote: String, owner: String, repo: String) -> Result { + let slug = cwd.slug(&owner, &repo); // Keep the list query shallow. Asking GraphQL to expand nested comments, // commits, reviews, and checks across 100 PRs can exceed GitHub's 500k // possible-node cap even for a modest repository. Rich fields load only // for the selected PR via `detail_github`. let (output, viewer) = thread::scope(|scope| { let viewer = scope.spawn(|| github_viewer(cwd)); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -957,15 +994,15 @@ fn list_github(cwd: &str, remote: String, owner: String, repo: String) -> Result } fn for_branch_github( - cwd: &str, + cwd: &GitHubContext<'_>, remote: String, owner: String, repo: String, branch: &str, ) -> Result> { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(&owner, &repo); let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1000,8 +1037,8 @@ fn for_branch_github( })) } -fn github_viewer(cwd: &str) -> Result { - let output = run_command( +fn github_viewer(cwd: &GitHubContext<'_>) -> Result { + let output = github_command( cwd, "gh", &["api", "user", "--jq", ".login"], @@ -1012,7 +1049,7 @@ fn github_viewer(cwd: &str) -> Result { #[allow(clippy::too_many_arguments)] fn create_github( - cwd: &str, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, source_branch: &str, @@ -1021,7 +1058,7 @@ fn create_github( description: &str, is_draft: bool, ) -> Result { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(&owner, &repo); let source_branch = branch_name(source_branch.to_string()); let target_branch = branch_name(target_branch.to_string()); let mut args = vec![ @@ -1041,7 +1078,7 @@ fn create_github( if is_draft { args.push("--draft"); } - let output = run_command_input( + let output = github_command_input( cwd, "gh", &args, @@ -1084,7 +1121,7 @@ fn map_github_create_error(error: String, source_branch: &str, target_branch: &s } fn activity_github( - cwd: &str, + cwd: &GitHubContext<'_>, remote: String, owner: String, repo: String, @@ -1094,7 +1131,7 @@ fn activity_github( let owner_arg = format!("owner={owner}"); let repo_arg = format!("repo={repo}"); let number = format!("number={id}"); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1112,16 +1149,16 @@ fn activity_github( PullRequestRepository { provider: PullRequestProvider::GitHub, remote, - label: format!("{owner}/{repo}"), + label: cwd.slug(&owner, &repo), viewer: None, }, ) } -fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result { - let slug = format!("{owner}/{repo}"); +fn detail_github(cwd: &GitHubContext<'_>, owner: String, repo: String, id: u64) -> Result { + let slug = cwd.slug(&owner, &repo); let id_string = id.to_string(); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1141,8 +1178,8 @@ fn detail_github(cwd: &str, owner: String, repo: String, id: u64) -> Result Result| cwd.scope_avatar(avatar); + for comment in &mut pull_request.comments { scope(&mut comment.avatar_url); } + for commit in &mut pull_request.commits { scope(&mut commit.avatar_url); } + for review in &mut pull_request.reviews { scope(&mut review.avatar_url); } + for thread in &mut pull_request.review_threads { + for comment in &mut thread.comments { scope(&mut comment.avatar_url); } + } + } Ok(pull_request) } fn github_review_threads( - cwd: &str, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64, @@ -1173,7 +1219,7 @@ fn github_review_threads( let owner = format!("owner={owner}"); let repo = format!("repo={repo}"); let number = format!("number={id}"); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1190,10 +1236,10 @@ fn github_review_threads( )) } -fn diff_github(cwd: &str, owner: String, repo: String, id: u64) -> Result { - let slug = format!("{owner}/{repo}"); +fn diff_github(cwd: &GitHubContext<'_>, owner: String, repo: String, id: u64) -> Result { + let slug = cwd.slug(&owner, &repo); let id = id.to_string(); - let output = run_command( + let output = github_command( cwd, "gh", &["pr", "diff", &id, "--repo", &slug, "--color", "never"], @@ -1206,10 +1252,10 @@ fn diff_github(cwd: &str, owner: String, repo: String, id: u64) -> Result Result<()> { - let slug = format!("{owner}/{repo}"); +fn add_comment_github(cwd: &GitHubContext<'_>, owner: String, repo: String, id: u64, body: &str) -> Result<()> { + let slug = cwd.slug(&owner, &repo); let id = id.to_string(); - run_command_input( + github_command_input( cwd, "gh", &["pr", "comment", &id, "--repo", &slug, "--body-file", "-"], @@ -1221,7 +1267,7 @@ fn add_comment_github(cwd: &str, owner: String, repo: String, id: u64, body: &st #[allow(clippy::too_many_arguments)] fn add_inline_comment_github( - cwd: &str, + cwd: &GitHubContext<'_>, owner: String, repo: String, id: u64, @@ -1237,7 +1283,7 @@ fn add_inline_comment_github( github_inline_comment_payload(body, file_path, start_line, end_line, side, expected_head); let input = serde_json::to_vec(&payload) .map_err(|error| format!("Could not encode GitHub inline comment: {error}"))?; - run_command_input( + github_command_input( cwd, "gh", &["api", "--method", "POST", &endpoint, "--input", "-"], @@ -1249,7 +1295,7 @@ fn add_inline_comment_github( #[allow(clippy::too_many_arguments)] fn submit_review_github( - cwd: &str, owner: &str, repo: &str, id: u64, event: PullRequestReviewEvent, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64, event: PullRequestReviewEvent, body: &str, comments: &[PullRequestPendingComment], expected_head: &str, ) -> Result<()> { let current = github_current_head(cwd, owner, repo, id)?; @@ -1257,17 +1303,17 @@ fn submit_review_github( let endpoint = format!("repos/{owner}/{repo}/pulls/{id}/reviews"); let input = serde_json::to_vec(&github_review_payload(event, body, comments, expected_head)) .map_err(|error| format!("Could not encode GitHub review: {error}"))?; - run_command_input( + github_command_input( cwd, "gh", &["api", "--method", "POST", &endpoint, "--input", "-"], &[("GH_PROMPT_DISABLED", "1")], Some(&input), )?; Ok(()) } -fn github_current_head(cwd: &str, owner: &str, repo: &str, id: u64) -> Result { - let slug = format!("{owner}/{repo}"); +fn github_current_head(cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64) -> Result { + let slug = cwd.slug(&owner, &repo); let id = id.to_string(); - let output = run_command( + let output = github_command( cwd, "gh", &["pr", "view", &id, "--repo", &slug, "--json", "headRefOid"], &[("GH_PROMPT_DISABLED", "1")], )?; @@ -1278,7 +1324,7 @@ fn github_current_head(cwd: &str, owner: &str, repo: &str, id: u64) -> Result, owner: &str, repo: &str, id: u64, @@ -1287,7 +1333,7 @@ fn update_branch_github( let endpoint = format!("repos/{owner}/{repo}/pulls/{id}/update-branch"); let input = serde_json::to_vec(&github_update_branch_payload(expected_head)) .map_err(|error| format!("Could not encode GitHub branch update: {error}"))?; - run_command_input( + github_command_input( cwd, "gh", &["api", "--method", "PUT", &endpoint, "--input", "-"], @@ -1298,16 +1344,16 @@ fn update_branch_github( } fn prepare_checkout_github( - cwd: &str, + cwd: &GitHubContext<'_>, remote: &str, owner: &str, repo: &str, id: u64, expected_head: &str, ) -> Result { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(&owner, &repo); let number = id.to_string(); - let output = run_command( + let output = github_command( cwd, "gh", &[ @@ -1331,7 +1377,7 @@ fn prepare_checkout_github( .filter(|branch| !branch.is_empty()) .ok_or_else(|| "GitHub did not return the pull request source branch".to_string())?; let pull_ref = github_pull_head_ref(id); - Repo::discover(cwd) + Repo::discover(cwd.path) .map_err(|error| error.to_string())? .fetch_refs_for_read(remote, &[&pull_ref]) .map_err(|error| format!("Could not fetch GitHub PR #{id} for a worktree: {error}"))?; @@ -1349,19 +1395,21 @@ fn github_pull_head_ref(id: u64) -> String { format!("refs/pull/{id}/head") } -fn reply_to_thread_github(cwd: &str, thread_id: &str, body: &str) -> Result { +fn reply_to_thread_github(cwd: &GitHubContext<'_>, thread_id: &str, body: &str) -> Result { let value = run_github_graphql_mutation( cwd, GITHUB_THREAD_REPLY_MUTATION, serde_json::json!({ "threadId": thread_id, "body": body }), )?; - parse_github_thread_reply(&value).ok_or_else(|| { + let mut reply = parse_github_thread_reply(&value).ok_or_else(|| { "GitHub accepted the reply request but returned an incomplete comment".to_string() - }) + })?; + cwd.scope_avatar(&mut reply.avatar_url); + Ok(reply) } fn set_thread_resolved_github( - cwd: &str, + cwd: &GitHubContext<'_>, thread_id: &str, resolved: bool, ) -> Result { @@ -1377,7 +1425,7 @@ fn set_thread_resolved_github( }) } -fn update_review_github(cwd: &str, review_id: &str, body: &str) -> Result<()> { +fn update_review_github(cwd: &GitHubContext<'_>, review_id: &str, body: &str) -> Result<()> { run_github_graphql_mutation( cwd, GITHUB_REVIEW_UPDATE_MUTATION, @@ -1386,7 +1434,7 @@ fn update_review_github(cwd: &str, review_id: &str, body: &str) -> Result<()> { Ok(()) } -fn dismiss_review_github(cwd: &str, review_id: &str, message: &str) -> Result<()> { +fn dismiss_review_github(cwd: &GitHubContext<'_>, review_id: &str, message: &str) -> Result<()> { run_github_graphql_mutation( cwd, GITHUB_REVIEW_DISMISS_MUTATION, @@ -1399,10 +1447,10 @@ fn github_graphql_payload(query: &str, variables: Value) -> Value { serde_json::json!({ "query": query, "variables": variables }) } -fn run_github_graphql_mutation(cwd: &str, query: &str, variables: Value) -> Result { +fn run_github_graphql_mutation(cwd: &GitHubContext<'_>, query: &str, variables: Value) -> Result { let input = serde_json::to_vec(&github_graphql_payload(query, variables)) .map_err(|error| format!("Could not encode GitHub review request: {error}"))?; - let output = run_command_input( + let output = github_command_input( cwd, "gh", &["api", "graphql", "--method", "POST", "--input", "-"], @@ -1475,16 +1523,16 @@ fn github_review_payload( } fn merge_github( - cwd: &str, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64, strategy: PullRequestMergeStrategy, expected_head: &str, ) -> Result<()> { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(&owner, &repo); let id = id.to_string(); - run_command( + github_command( cwd, "gh", &[ @@ -1502,10 +1550,10 @@ fn merge_github( Ok(()) } -fn mark_ready_github(cwd: &str, owner: &str, repo: &str, id: u64) -> Result<()> { - let slug = format!("{owner}/{repo}"); +fn mark_ready_github(cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64) -> Result<()> { + let slug = cwd.slug(&owner, &repo); let id = id.to_string(); - run_command( + github_command( cwd, "gh", &["pr", "ready", &id, "--repo", &slug], @@ -1515,16 +1563,16 @@ fn mark_ready_github(cwd: &str, owner: &str, repo: &str, id: u64) -> Result<()> } fn set_lifecycle_github( - cwd: &str, + cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64, action: PullRequestLifecycleAction, ) -> Result<()> { - let slug = format!("{owner}/{repo}"); + let slug = cwd.slug(&owner, &repo); let id = id.to_string(); let verb = github_lifecycle_verb(action); - run_command( + github_command( cwd, "gh", &["pr", verb, &id, "--repo", &slug], @@ -3467,6 +3515,8 @@ fn auth_hint(program: &str, stderr: &str) -> &'static str { "" } else if program == "gh" { " Sign in with `gh auth login`, then try again." + } else if program == "glab" { + " Sign in with `glab auth login --hostname HOST` for this remote, then try again." } else { " Sign in with `az login`, then try again." } @@ -4051,6 +4101,7 @@ fn parse_github_pr(value: &Value, viewer: Option<&str>) -> Option { }) .collect(); Some(PullRequest { + capabilities: None, id, title: text(value.get("title")).unwrap_or_default(), state: text(value.get("state")) @@ -4370,6 +4421,7 @@ fn parse_azure_pr( "review required".into() }; Some(PullRequest { + capabilities: None, id, title: text(value.get("title")).unwrap_or_default(), state: text(value.get("status")) @@ -4638,7 +4690,49 @@ fn branch_name(value: String) -> String { .to_string() } +fn parse_hosted_remote(remote: &str, configured: Option<&str>) -> Option { + let remote = if remote.contains("://") { remote.to_string() } + else { let (user_host, path) = remote.split_once(':')?; format!("ssh://{user_host}/{path}") }; + let url = url::Url::parse(&remote).ok()?; + if !matches!(url.scheme(), "https" | "ssh") || url.password().is_some() || url.query().is_some() || url.fragment().is_some() { return None; } + let host = format!("{}{}", url.host_str()?, url.port().map(|p| format!(":{p}")).unwrap_or_default()); + transport::validate_host(&host).ok()?; + let path = url.path().trim_matches('/').trim_end_matches(".git"); + let parts = path.split('/').map(percent_decode).collect::>(); + if parts.len() < 2 || parts.iter().any(|p| p.is_empty() || p == "." || p == ".." || p.contains(['/', '\\', '\0', '\r', '\n'])) { return None; } + let provider = match host.as_str() { "github.com" => "github", "gitlab.com" => "gitlab", "bitbucket.org" => "bitbucket", _ => configured? }; + match provider { + "github" if parts.len() == 2 => Some(HostRepo::GitHub { host, owner: parts[0].clone(), repo: parts[1].clone() }), + "gitlab" | "bitbucket" if provider == "gitlab" || host == "bitbucket.org" && parts.len() == 2 => Some(HostRepo::Hosted(hosted::HostedRepo { + provider: provider.into(), host, namespace: parts[..parts.len()-1].join("/"), repo: parts.last()?.clone(), + })), + _ => None, + } +} + +#[derive(Debug, Serialize)] +pub struct RemoteHostingProvider { pub remote: String, pub url: String, pub provider: String } + +pub fn hosting_providers(path: &str) -> Result> { + let repo = Repo::discover(path).map_err(|e| e.to_string())?; + repo.refs().map_err(|e| e.to_string())?.remotes.into_iter().map(|remote| { + let url = repo.configured_remote_url(&remote.name).map_err(|e| e.to_string())?.unwrap_or_default(); + let provider = run_command(path, "git", &["config", "--get", &format!("remote.{}.strand-provider", remote.name)], &[]).ok().and_then(|v| String::from_utf8(v).ok()).unwrap_or_default().trim().to_string(); + Ok(RemoteHostingProvider { remote: remote.name, url, provider }) + }).collect() +} + +pub fn set_hosting_provider(path: &str, remote: &str, provider: &str) -> Result<()> { + let remotes = hosting_providers(path)?; + let remote = remotes.iter().find(|r| r.remote == remote).ok_or("Remote no longer exists")?; + if !matches!(provider, "" | "github" | "gitlab") { return Err("Select automatic detection, GitHub, or GitLab".into()); } + if !provider.is_empty() && parse_hosted_remote(&remote.url, Some(provider)).is_none() { return Err("This remote has no supported HTTPS/SSH repository coordinates".into()); } + run_command(path, "git", &["config", "--local", &format!("remote.{}.strand-provider", remote.remote), provider], &[])?; + Ok(()) +} + fn parse_remote(url: &str) -> Option { + if let Some(host) = parse_hosted_remote(url, None) { return Some(host); } let trimmed = url.trim().trim_end_matches(".git").trim_end_matches('/'); if let Some(rest) = trimmed .strip_prefix("https://github.com/") @@ -4648,6 +4742,7 @@ fn parse_remote(url: &str) -> Option { { let mut parts = rest.split('/'); return Some(HostRepo::GitHub { + host: "github.com".into(), owner: percent_decode(parts.next()?), repo: percent_decode(parts.next()?), }); @@ -4750,6 +4845,17 @@ mod tests { use super::*; use std::{path::Path, process::Command}; + #[test] + fn custom_hosts_require_explicit_adapter_and_preserve_auth_coordinates() { + assert!(parse_hosted_remote("git@enterprise.example:team/repo.git", None).is_none()); + assert_eq!(parse_hosted_remote("ssh://git@enterprise.example:8443/team/repo.git", Some("github")), Some(HostRepo::GitHub {host:"enterprise.example:8443".into(),owner:"team".into(),repo:"repo".into()})); + assert!(matches!(parse_hosted_remote("https://gitlab.example/group/sub/repo.git",Some("gitlab")), Some(HostRepo::Hosted(host)) if host.namespace == "group/sub" && host.host == "gitlab.example")); + assert!(parse_hosted_remote("https://bitbucket.example/projects/A/repos/b",Some("bitbucket")).is_none()); + assert!(parse_hosted_remote("https://enterprise.example/team/repo/extra",Some("github")).is_none()); + assert!(parse_hosted_remote("https://token:secret@enterprise.example/team/repo",Some("github")).is_none()); + assert!(parse_hosted_remote("https://enterprise.example/team%2Frepo/app",Some("github")).is_none()); + } + fn git(dir: &Path, args: &[&str]) -> String { let output = Command::new("git") .current_dir(dir) @@ -4769,6 +4875,7 @@ mod tests { assert_eq!( parse_remote("git@github.com:openai/codex.git"), Some(HostRepo::GitHub { + host: "github.com".into(), owner: "openai".into(), repo: "codex".into() }) @@ -4801,8 +4908,8 @@ mod tests { #[test] fn rejects_unimplemented_hosts() { - assert_eq!(parse_remote("git@gitlab.com:acme/web.git"), None); - assert_eq!(parse_remote("https://bitbucket.org/acme/web.git"), None); + assert!(matches!(parse_remote("git@gitlab.com:acme/web.git"), Some(HostRepo::Hosted(_)))); + assert!(matches!(parse_remote("https://bitbucket.org/acme/web.git"), Some(HostRepo::Hosted(_)))); } #[test] diff --git a/crates/strand-tauri/src/pull_requests/hosted.rs b/crates/strand-tauri/src/pull_requests/hosted.rs new file mode 100644 index 00000000..2261268b --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/hosted.rs @@ -0,0 +1,1327 @@ +//! GitLab and Bitbucket Cloud adapters. Existing GitHub/Azure paths stay separate. +use super::transport::{pages, segment, Api, Client}; +use super::*; +use serde_json::json; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct HostedRepo { + pub provider: String, + pub host: String, + pub namespace: String, + pub repo: String, +} + +impl HostedRepo { + fn bb(&self) -> bool { + self.provider == "bitbucket" + } + fn root(&self) -> String { + if self.bb() { + format!( + "repositories/{}/{}", + segment(&self.namespace), + segment(&self.repo) + ) + } else { + format!( + "projects/{}", + segment(&format!("{}/{}", self.namespace, self.repo)) + ) + } + } + fn prs(&self) -> String { + format!( + "{}/{}", + self.root(), + if self.bb() { + "pullrequests" + } else { + "merge_requests" + } + ) + } + fn pr(&self, id: u64) -> String { + format!("{}/{id}", self.prs()) + } + pub fn client<'a>(&'a self, cwd: &'a str) -> Client<'a> { + Client { + cwd, + provider: &self.provider, + host: &self.host, + } + } + fn repository(&self, remote: String, viewer: Option) -> PullRequestRepository { + PullRequestRepository { + provider: if self.bb() { + PullRequestProvider::Bitbucket + } else { + PullRequestProvider::GitLab + }, + remote, + label: format!("{}/{}/{}", self.host, self.namespace, self.repo), + viewer, + } + } + fn viewer(&self, api: &impl Api) -> Result { + api.json("GET", "user", None) + } + fn viewer_name(&self, viewer: &Value) -> String { + field( + viewer, + if self.bb() { + "/display_name" + } else { + "/username" + }, + ) + } + fn same_user(&self, a: &Value, b: &Value) -> bool { + let key = if self.bb() { "uuid" } else { "id" }; + a.get(key) + .is_some_and(|id| !id.is_null() && b.get(key) == Some(id)) + } + pub fn list( + &self, + api: &impl Api, + remote: String, + branch: Option<&str>, + ) -> Result { + let query = if self.bb() { + let filter = branch + .map(|b| { + format!( + "&q={}", + segment(&format!( + "source.branch.name={}", + json!(b.trim_start_matches("refs/heads/")) + )) + ) + }) + .unwrap_or_default(); + format!("{}?state=OPEN&state=MERGED&state=DECLINED&state=SUPERSEDED&sort=-updated_on{filter}", self.prs()) + } else { + format!( + "{}?scope=all&state=all&order_by=updated_at&sort=desc{}", + self.prs(), + branch + .map(|b| format!( + "&source_branch={}", + segment(b.trim_start_matches("refs/heads/")) + )) + .unwrap_or_default() + ) + }; + let viewer = self.viewer(api)?; + let values = if branch.is_some() { + let value = api.json( + "GET", + &format!( + "{query}&{}=1", + if self.bb() { "pagelen" } else { "per_page" } + ), + None, + )?; + (if self.bb() { + value.get("values") + } else { + Some(&value) + }) + .and_then(Value::as_array) + .ok_or("Provider returned an invalid branch request lookup")? + .clone() + } else { + pages(api, &query, self.bb())? + }; + Ok(PullRequestList { + repository: self.repository(remote, Some(self.viewer_name(&viewer))), + pull_requests: values + .iter() + .map(|v| self.parse(v, &viewer)) + .collect::>()?, + }) + } + fn parse(&self, v: &Value, viewer: &Value) -> Result { + let id = v + .get(if self.bb() { "id" } else { "iid" }) + .and_then(Value::as_u64) + .ok_or("Provider returned no request number")?; + let bb = self.bb(); + let author = &v["author"]; + let state = field(v, "/state"); + let state = match state.as_str() { + "opened" | "OPEN" => "open", + "MERGED" => "merged", + "DECLINED" | "SUPERSEDED" => "closed", + _ => &state, + } + .to_string(); + let authored = self.same_user(author, viewer); + let mut pr = PullRequest { + capabilities: Some(PullRequestCapabilities::default()), + id, + title: field(v, "/title"), + state, + is_draft: v["draft"].as_bool().unwrap_or(false), + author: self.viewer_name(author), + authored_by_viewer: authored, + source_branch: field( + v, + if bb { + "/source/branch/name" + } else { + "/source_branch" + }, + ), + source_commit: field(v, if bb { "/source/commit/hash" } else { "/sha" }), + target_branch: field( + v, + if bb { + "/destination/branch/name" + } else { + "/target_branch" + }, + ), + created_at: field(v, if bb { "/created_on" } else { "/created_at" }), + updated_at: field(v, if bb { "/updated_on" } else { "/updated_at" }), + completed_at: text(v.get("merged_at")).or_else(|| text(v.get("closed_at"))), + url: field(v, if bb { "/links/html/href" } else { "/web_url" }), + description: field(v, "/description"), + merge_status: field(v, "/detailed_merge_status"), + comment_count: v[if bb { + "comment_count" + } else { + "user_notes_count" + }] + .as_u64() + .unwrap_or(0) as usize, + labels: array(v, "labels") + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + ..PullRequest::default() + }; + if !bb { + pr.merge_status = match pr.merge_status.as_str() { + "mergeable" => "CLEAN", + "conflict" => "CONFLICTING", + "checking" | "approvals_syncing" => "CHECKING", + x => x, + } + .into(); + } + pr.reviewers = array(v, if bb { "participants" } else { "reviewers" }) + .iter() + .filter(|r| !bb || r["role"] == "REVIEWER") + .map(|r| PullRequestReviewer { + name: self.viewer_name(if bb { &r["user"] } else { r }), + required: false, + status: if r["approved"] == true { + "APPROVED" + } else if r["state"] == "changes_requested" { + "CHANGES_REQUESTED" + } else { + "REQUESTED" + } + .into(), + }) + .collect(); + Ok(pr) + } + fn permissions( + &self, + api: &impl Api, + v: &Value, + viewer: &Value, + ) -> Result { + let open = matches!(field(v, "/state").as_str(), "opened" | "OPEN"); + let author = self.same_user(&v["author"], viewer); + let write = if self.bb() { + let query = format!( + "user/workspaces/{}/permissions/repositories?q={}", + segment(&self.namespace), + segment(&format!( + "repository.full_name={}", + json!(format!("{}/{}", self.namespace, self.repo)) + )) + ); + pages(api, &query, true)? + .iter() + .any(|p| matches!(p["permission"].as_str(), Some("write" | "admin"))) + } else { + let project = api.json("GET", &self.root(), None)?; + [ + "/permissions/project_access/access_level", + "/permissions/group_access/access_level", + ] + .iter() + .any(|p| project.pointer(p).and_then(Value::as_u64).unwrap_or(0) >= 30) + }; + Ok(PullRequestCapabilities { + can_comment: open, + can_review: open && !author, + can_request_changes: open && !author && self.bb(), + can_close: open && (write || author), + can_reopen: !open && (write || author) && !self.bb() && v["state"] == "closed", + // Cloud merge has no expected-head guard. GitLab's merge method + // belongs to project settings; do not reinterpret it as rebase. + merge_strategies: if open && write && !self.bb() { + vec!["merge_commit".into(), "squash".into()] + } else { + vec![] + }, + }) + } + pub fn detail(&self, api: &impl Api, id: u64) -> Result { + let v = api.json("GET", &self.pr(id), None)?; + let viewer = self.viewer(api)?; + let mut pr = self.parse(&v, &viewer)?; + let capabilities = self.permissions(api, &v, &viewer)?; + pr.can_mark_ready = pr.is_draft && capabilities.can_close && !self.bb(); + pr.capabilities = Some(capabilities); + let commits = pages(api, &format!("{}/commits", self.pr(id)), self.bb())?; + pr.commits = commits + .iter() + .map(|c| PullRequestCommit { + id: field(c, if self.bb() { "/hash" } else { "/id" }), + title: field(c, if self.bb() { "/message" } else { "/title" }), + author: field( + c, + if self.bb() { + "/author/raw" + } else { + "/author_name" + }, + ), + avatar_url: None, + committed_at: field( + c, + if self.bb() { + "/date" + } else { + "/committed_date" + }, + ), + url: text(c.pointer(if self.bb() { + "/links/html/href" + } else { + "/web_url" + })), + }) + .collect(); + pr.commit_count = pr.commits.len(); + let discussions = pages( + api, + &format!( + "{}/{}", + self.pr(id), + if self.bb() { "comments" } else { "discussions" } + ), + self.bb(), + )?; + self.discussions(&mut pr, &discussions); + if self.bb() { + let checks = pages(api, &format!("{}/statuses", self.pr(id)), true)?; + pr.checks = checks + .iter() + .map(|c| PullRequestCheck { + name: field(c, "/name"), + status: field(c, "/state"), + }) + .collect(); + // Commit statuses do not describe all Cloud merge restrictions. + pr.checks_complete = false; + } else { + if let Some(pipeline) = v.get("head_pipeline").filter(|p| !p.is_null()) { + pr.checks.push(PullRequestCheck { + name: "Head pipeline".into(), + status: field(pipeline, "/status"), + }); + } + // Approvals may be unavailable by tier/permission; absence is not green. + if let Ok(approval) = api.json("GET", &format!("{}/approvals", self.pr(id)), None) { + pr.review_status = if approval["approvals_left"].as_u64().unwrap_or(1) == 0 { + "APPROVED" + } else { + "REVIEW_REQUIRED" + } + .into(); + pr.reviews = array(&approval, "approved_by") + .iter() + .map(|r| PullRequestReview { + id: format!("gitlab:{id}:{}", r["user"]["id"]), + author: self.viewer_name(&r["user"]), + avatar_url: text(r["user"].get("avatar_url")), + state: "APPROVED".into(), + body: String::new(), + submitted_at: String::new(), + url: pr.url.clone(), + can_update: false, + can_dismiss: false, + }) + .collect(); + } + } + Ok(pr) + } + fn comment(&self, v: &Value, id: u64, url: &str) -> PullRequestComment { + let raw_id = v["id"].as_u64().unwrap_or(0); + PullRequestComment { + id: format!("{}:{id}:{raw_id}", self.provider), + author: self.viewer_name(&v[if self.bb() { "user" } else { "author" }]), + avatar_url: text(v.pointer(if self.bb() { + "/user/links/avatar/href" + } else { + "/author/avatar_url" + })), + body: field(v, if self.bb() { "/content/raw" } else { "/body" }), + created_at: field( + v, + if self.bb() { + "/created_on" + } else { + "/created_at" + }, + ), + url: format!( + "{url}#{}_{raw_id}", + if self.bb() { "comment" } else { "note" } + ), + is_system: v["system"] == true, + path: text(v.pointer(if self.bb() { + "/inline/path" + } else { + "/position/new_path" + })), + } + } + fn discussions(&self, pr: &mut PullRequest, values: &[Value]) { + let writable = pr.capabilities.as_ref().is_some_and(|c| c.can_comment); + let mut children: HashMap> = HashMap::new(); + if self.bb() { + for value in values.iter().filter(|v| v["deleted"] != true) { + pr.comments.push(self.comment(value, pr.id, &pr.url)); + if let Some(parent) = value["parent"]["id"].as_u64() { + children.entry(parent).or_default().push(value); + } + } + } + for v in values { + if self.bb() && (v["deleted"] == true || v.get("parent").is_some_and(|p| !p.is_null())) + { + continue; + } + let notes = if self.bb() { + vec![v] + } else { + array(v, "notes").iter().collect() + }; + let Some(first) = notes.first() else { + continue; + }; + let mut comments = notes + .iter() + .map(|n| self.comment(n, pr.id, &pr.url)) + .collect::>(); + if self.bb() { + let mut pending = vec![v["id"].as_u64().unwrap_or(0)]; + let mut visited = std::collections::HashSet::new(); + while let Some(parent) = pending.pop() { + if !visited.insert(parent) { + continue; + } + for reply in children.get(&parent).into_iter().flatten() { + comments.push(self.comment(reply, pr.id, &pr.url)); + if let Some(id) = reply["id"].as_u64() { + pending.push(id); + } + } + } + } else { + pr.comments.extend(comments.clone()); + } + let position = &first[if self.bb() { "inline" } else { "position" }]; + let old = if self.bb() { "from" } else { "old_line" }; + let new = if self.bb() { "to" } else { "new_line" }; + let addition = position[new].as_u64().is_some(); + let line = position[if addition { new } else { old }] + .as_u64() + .unwrap_or(0) as u32; + if line == 0 { + continue; + } + let resolved = if self.bb() { + first.get("resolution").is_some_and(|r| !r.is_null()) + } else { + first["resolved"] == true + }; + let outdated = !self.bb() + && position["head_sha"] + .as_str() + .is_some_and(|h| h != pr.source_commit); + let can_resolve = !self.bb() + && writable + && first["resolvable"] == true + && pr.capabilities.as_ref().is_some_and(|c| c.can_close); + let discussion_id = if self.bb() { + v["id"].to_string() + } else { + field(v, "/id") + }; + pr.review_threads.push(PullRequestReviewThread { + id: format!("{}:{}:{discussion_id}", self.provider, pr.id), + path: field( + position, + if self.bb() { + "/path" + } else if addition { + "/new_path" + } else { + "/old_path" + }, + ), + start_line: (if self.bb() { + position.get(if addition { "start_to" } else { "start_from" }) + } else { + position.pointer(if addition { + "/line_range/start/new_line" + } else { + "/line_range/start/old_line" + }) + }) + .and_then(Value::as_u64) + .unwrap_or(line as u64) as u32, + end_line: line, + side: if addition { + PullRequestDiffSide::Additions + } else { + PullRequestDiffSide::Deletions + }, + is_resolved: resolved, + is_outdated: outdated, + can_reply: writable, + can_resolve: can_resolve && !resolved, + can_unresolve: can_resolve && resolved, + comments, + }); + } + pr.comment_count = pr.comments.len(); + } + pub fn diff(&self, api: &impl Api, id: u64) -> Result { + let bytes = api.request( + "GET", + &format!( + "{}/{}", + self.pr(id), + if self.bb() { "diff" } else { "raw_diffs" } + ), + None, + )?; + String::from_utf8(bytes).map_err(|_| "Provider returned a non-UTF-8 patch".into()) + } + fn current(&self, api: &impl Api, id: u64, expected: Option<&str>) -> Result { + let v = api.json("GET", &self.pr(id), None)?; + if !matches!(field(&v, "/state").as_str(), "opened" | "OPEN") { + return Err("This request is no longer open; refresh before writing".into()); + } + if let Some(expected) = expected { + ensure_review_head( + &field( + &v, + if self.bb() { + "/source/commit/hash" + } else { + "/sha" + }, + ), + expected, + )?; + } + Ok(v) + } + pub fn add_comment(&self, api: &impl Api, id: u64, body: &str) -> Result<()> { + self.current(api, id, None)?; + api.json( + "POST", + &format!( + "{}/{}", + self.pr(id), + if self.bb() { "comments" } else { "notes" } + ), + Some(&if self.bb() { + json!({"content":{"raw":body}}) + } else { + json!({"body":body}) + }), + )?; + Ok(()) + } + pub fn inline( + &self, + api: &impl Api, + id: u64, + comment: &PullRequestPendingComment, + head: &str, + ) -> Result<()> { + let v = self.current(api, id, Some(head))?; + if self.bb() { + // Cloud does not accept an immutable commit coordinate on comments. + // Detect changes before and after; never report a raced write as safe. + let payload = bitbucket_inline(comment); + api.json("POST", &format!("{}/comments", self.pr(id)), Some(&payload))?; + self.current(api, id, Some(head)).map_err(|e| format!("Comment was posted, but the head changed. Inspect it on Bitbucket before retrying: {e}"))?; + } else { + let diffs = pages(api, &format!("{}/diffs", self.pr(id)), false)?; + let payload = gitlab_inline(&v, &diffs, comment, head)?; + self.current(api, id, Some(head))?; + api.json( + "POST", + &format!("{}/discussions", self.pr(id)), + Some(&payload), + )?; + } + Ok(()) + } + pub fn review( + &self, + api: &impl Api, + id: u64, + event: PullRequestReviewEvent, + body: &str, + comments: &[PullRequestPendingComment], + head: &str, + ) -> Result<()> { + let v = self.current(api, id, Some(head))?; + let viewer = self.viewer(api)?; + if event != PullRequestReviewEvent::Comment && self.same_user(&v["author"], &viewer) { + return Err("You cannot review your own request".into()); + } + if event == PullRequestReviewEvent::RequestChanges && !self.bb() { + return Err("Request changes on the GitLab website; this adapter supports comments and approvals".into()); + } + let mut posted = 0; + let result = (|| { + if event != PullRequestReviewEvent::Comment { + self.current(api, id, Some(head))?; + let verb = if event == PullRequestReviewEvent::Approve { + "approve" + } else { + "request-changes" + }; + api.json( + "POST", + &format!("{}/{verb}", self.pr(id)), + Some(&if self.bb() { + json!({}) + } else { + json!({"sha":head}) + }), + )?; + posted += 1; + } + for comment in comments { + self.inline(api, id, comment, head)?; + posted += 1; + } + if !body.trim().is_empty() { + self.current(api, id, Some(head))?; + self.add_comment(api, id, body)?; + posted += 1; + } + self.current(api, id, Some(head))?; + Ok(()) + })(); + result.map_err(|e: String| format!("{posted} review writes were confirmed; draft retained. Refresh and reconcile posted items before retrying. {e}")) + } + fn thread<'a>(&self, thread: &'a str) -> Result<(u64, &'a str)> { + let parts = thread.split(':').collect::>(); + if parts.len() != 3 + || parts[0] != self.provider + || !parts[2].bytes().all(|b| b.is_ascii_alphanumeric()) + { + return Err("Invalid provider discussion ID".into()); + } + Ok(( + parts[1].parse().map_err(|_| "Invalid request ID")?, + parts[2], + )) + } + pub fn reply(&self, api: &impl Api, thread: &str, body: &str) -> Result { + let (id, discussion) = self.thread(thread)?; + let current = self.current(api, id, None)?; + let url = field( + ¤t, + if self.bb() { + "/links/html/href" + } else { + "/web_url" + }, + ); + let endpoint = if self.bb() { + format!("{}/comments", self.pr(id)) + } else { + format!("{}/discussions/{discussion}/notes", self.pr(id)) + }; + let payload = if self.bb() { + json!({"content":{"raw":body},"parent":{"id":discussion.parse::().map_err(|_| "Invalid comment ID")?}}) + } else { + json!({"body":body}) + }; + let reply = api.json("POST", &endpoint, Some(&payload))?; + Ok(self.comment(&reply, id, &url)) + } + pub fn resolve( + &self, + api: &impl Api, + thread: &str, + resolved: bool, + ) -> Result { + if self.bb() { + return Err("Resolve this discussion on Bitbucket".into()); + } + let (id, discussion) = self.thread(thread)?; + let current = self.current(api, id, None)?; + let updated = api.json( + "PUT", + &format!("{}/discussions/{discussion}", self.pr(id)), + Some(&json!({"resolved":resolved})), + )?; + let note = array(&updated, "notes") + .iter() + .find(|n| n["resolvable"] == true) + .ok_or( + "GitLab updated the thread but returned no resolvable note; refresh its state", + )?; + let resolved = note["resolved"] + .as_bool() + .ok_or("GitLab returned no resolution state")?; + Ok(PullRequestReviewThreadUpdate { + id: thread.into(), + is_resolved: resolved, + is_outdated: note + .pointer("/position/head_sha") + .and_then(Value::as_str) + .is_some_and(|h| h != field(¤t, "/sha")), + can_reply: true, + can_resolve: !resolved, + can_unresolve: resolved, + }) + } + pub fn merge( + &self, + api: &impl Api, + id: u64, + strategy: PullRequestMergeStrategy, + head: &str, + ) -> Result<()> { + if self.bb() { + return Err( + "Merge on Bitbucket: its Cloud API cannot atomically guard the reviewed head" + .into(), + ); + } + if strategy == PullRequestMergeStrategy::Rebase { + return Err("GitLab merge method is controlled by project settings".into()); + } + self.current(api, id, Some(head))?; + api.json("PUT", &format!("{}/merge", self.pr(id)), Some(&json!({"sha":head,"squash":strategy == PullRequestMergeStrategy::Squash,"should_remove_source_branch":false})))?; + Ok(()) + } + pub fn lifecycle( + &self, + api: &impl Api, + id: u64, + action: PullRequestLifecycleAction, + ) -> Result<()> { + let v = api.json("GET", &self.pr(id), None)?; + let viewer = self.viewer(api)?; + let caps = self.permissions(api, &v, &viewer)?; + if !(if action == PullRequestLifecycleAction::Close { + caps.can_close + } else { + caps.can_reopen + }) { + return Err("This lifecycle action is not available to the signed-in account".into()); + } + if self.bb() { + api.json( + "POST", + &format!("{}/decline", self.pr(id)), + Some(&json!({})), + )?; + } else { + api.json("PUT", &self.pr(id), Some(&json!({"state_event":if action == PullRequestLifecycleAction::Close { "close" } else { "reopen" }})))?; + } + Ok(()) + } + pub fn ready(&self, api: &impl Api, id: u64) -> Result<()> { + if self.bb() { + return Err("Manage drafts on Bitbucket".into()); + } + let v = self.current(api, id, None)?; + let title = field(&v, "/title"); + let lower = title.to_ascii_lowercase(); + let prefix = ["draft:", "[draft]", "(draft)", "wip:", "[wip]", "(wip)"] + .iter() + .find(|prefix| lower.starts_with(**prefix)) + .ok_or("Unrecognized GitLab draft title; mark ready on the provider")?; + let title = title[prefix.len()..].trim_start(); + let updated = api.json("PUT", &self.pr(id), Some(&json!({"title":title})))?; + if updated["draft"] == true || updated["work_in_progress"] == true { + return Err( + "GitLab still reports this request as a draft; inspect it on the provider".into(), + ); + } + Ok(()) + } + pub fn create( + &self, + api: &impl Api, + source: &str, + target: &str, + title: &str, + description: &str, + draft: bool, + ) -> Result { + let payload = if self.bb() { + json!({"title":title,"description":description,"draft":draft,"source":{"branch":{"name":source}},"destination":{"branch":{"name":target}},"close_source_branch":false}) + } else { + json!({"source_branch":source,"target_branch":target,"title":if draft { format!("Draft: {title}") } else { title.into() },"description":description,"remove_source_branch":false}) + }; + let v = api.json("POST", &self.prs(), Some(&payload))?; + Ok(PullRequestCreateOutcome { + id: v[if self.bb() { "id" } else { "iid" }].as_u64().ok_or( + "Request created but number unavailable; check the provider before retrying", + )?, + url: field( + &v, + if self.bb() { + "/links/html/href" + } else { + "/web_url" + }, + ), + }) + } + pub fn checkout( + &self, + api: &impl Api, + cwd: &str, + remote: &str, + id: u64, + head: &str, + ) -> Result { + let v = api.json("GET", &self.pr(id), None)?; + ensure_review_head( + &field( + &v, + if self.bb() { + "/source/commit/hash" + } else { + "/sha" + }, + ), + head, + )?; + let branch = field( + &v, + if self.bb() { + "/source/branch/name" + } else { + "/source_branch" + }, + ); + let reference = if self.bb() { + if field(&v, "/source/repository/full_name") + != format!("{}/{}", self.namespace, self.repo) + { + return Err("Clone the Bitbucket fork to open this source branch locally".into()); + } + format!("refs/heads/{branch}") + } else { + format!("refs/merge-requests/{id}/head") + }; + Repo::discover(cwd) + .map_err(|e| e.to_string())? + .fetch_refs_for_read(remote, &[&reference]) + .map_err(|e| e.to_string())?; + Ok(PullRequestCheckoutPreparation { + branch, + start_point: head.into(), + }) + } + pub fn activity( + &self, + api: &impl Api, + remote: String, + id: u64, + ) -> Result { + // Monitoring must not reload commits, permissions, or patches. + let value = api.json("GET", &self.pr(id), None)?; + let mut pr = self.parse(&value, &Value::Null)?; + let discussions = pages( + api, + &format!( + "{}/{}", + self.pr(id), + if self.bb() { "comments" } else { "discussions" } + ), + self.bb(), + )?; + self.discussions(&mut pr, &discussions); + if self.bb() { + pr.checks = pages(api, &format!("{}/statuses", self.pr(id)), true)? + .iter() + .map(|c| PullRequestCheck { + name: field(c, "/name"), + status: field(c, "/state"), + }) + .collect(); + pr.reviews = array(&value, "participants") + .iter() + .filter(|r| r["approved"] == true || r["state"] == "changes_requested") + .map(|r| PullRequestReview { + id: field(r, "/user/uuid"), + author: self.viewer_name(&r["user"]), + avatar_url: None, + state: if r["approved"] == true { + "APPROVED" + } else { + "CHANGES_REQUESTED" + } + .into(), + body: String::new(), + submitted_at: String::new(), + url: pr.url.clone(), + can_update: false, + can_dismiss: false, + }) + .collect(); + } else { + if let Some(pipeline) = value.get("head_pipeline").filter(|v| !v.is_null()) { + pr.checks.push(PullRequestCheck { + name: "Head pipeline".into(), + status: field(pipeline, "/status"), + }); + } + if let Ok(approval) = api.json("GET", &format!("{}/approvals", self.pr(id)), None) { + pr.reviews = array(&approval, "approved_by") + .iter() + .map(|r| PullRequestReview { + id: r["user"]["id"].to_string(), + author: self.viewer_name(&r["user"]), + avatar_url: None, + state: "APPROVED".into(), + body: String::new(), + submitted_at: String::new(), + url: pr.url.clone(), + can_update: false, + can_dismiss: false, + }) + .collect(); + } + } + Ok(PullRequestActivitySnapshot { + repository: self.repository(remote, None), + id, + title: pr.title, + url: pr.url, + state: pr.state, + source_branch: pr.source_branch, + source_commit: pr.source_commit, + updated_at: pr.updated_at, + comments: pr + .comments + .into_iter() + .map(|c| PullRequestActivityComment { + id: c.id, + author: c.author, + kind: "comment".into(), + is_system: c.is_system, + }) + .collect(), + reviews: pr + .reviews + .into_iter() + .map(|r| PullRequestActivityReview { + id: r.id, + author: r.author, + state: r.state, + }) + .collect(), + checks: pr + .checks + .into_iter() + .map(|c| PullRequestActivityCheck { + id: c.name.clone(), + name: c.name, + status: c.status, + }) + .collect(), + checks_complete: pr.checks_complete, + }) + } +} + +fn field(v: &Value, pointer: &str) -> String { + v.pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_default() + .into() +} + +fn bitbucket_inline(c: &PullRequestPendingComment) -> Value { + let mut inline = json!({"path":c.path}); + let addition = c.side == PullRequestDiffSide::Additions; + inline[if addition { "to" } else { "from" }] = c.end_line.into(); + if c.start_line != c.end_line { + inline[if addition { "start_to" } else { "start_from" }] = c.start_line.into(); + } + json!({"content":{"raw":c.body},"inline":inline}) +} + +fn gitlab_inline( + v: &Value, + diffs: &[Value], + c: &PullRequestPendingComment, + head: &str, +) -> Result { + let refs = &v["diff_refs"]; + ensure_review_head(&field(refs, "/head_sha"), head)?; + for key in ["base_sha", "start_sha", "head_sha"] { + validate_commit(&field(refs, &format!("/{key}")))?; + } + let addition = c.side == PullRequestDiffSide::Additions; + let diff = diffs + .iter() + .find(|d| d[if addition { "new_path" } else { "old_path" }].as_str() == Some(&c.path)) + .ok_or("Selected path is absent from this GitLab diff version")?; + if diff["too_large"] == true || diff["collapsed"] == true { + return Err("GitLab omitted this file's diff; comment on the provider website".into()); + } + let mut position = json!({"position_type":"text","base_sha":refs["base_sha"],"start_sha":refs["start_sha"],"head_sha":refs["head_sha"],"old_path":diff["old_path"],"new_path":diff["new_path"]}); + let patch = diff["diff"] + .as_str() + .ok_or("GitLab omitted the file patch needed for line coordinates")?; + let end = gitlab_line(patch, c.end_line, addition)?; + if let Some(old) = end.old { + position["old_line"] = old.into(); + } + if let Some(new) = end.new { + position["new_line"] = new.into(); + } + if c.start_line != c.end_line { + let start = gitlab_line(patch, c.start_line, addition)?; + if start.hunk != end.hunk { + return Err("Select a GitLab comment range within one diff hunk".into()); + } + let filename = if diff["deleted_file"] == true { + field(diff, "/old_path") + } else { + field(diff, "/new_path") + }; + let hash = sha1_smol::Sha1::from(filename.as_bytes()) + .digest() + .to_string(); + let coordinate = |line: &GitLabLine| json!({"line_code":format!("{hash}_{}_{}",line.old.unwrap_or(0),line.new.unwrap_or(0)),"type":if line.old.is_none() {"new"} else {"old"},"old_line":line.old,"new_line":line.new}); + position["line_range"] = json!({"start":coordinate(&start),"end":coordinate(&end)}); + } + Ok(json!({"body":c.body,"position":position})) +} + +struct GitLabLine { + old: Option, + new: Option, + hunk: usize, +} + +fn gitlab_line(patch: &str, wanted: u32, addition: bool) -> Result { + let (mut old, mut new, mut hunk) = (0_u32, 0_u32, 0); + for line in patch.lines() { + if line.starts_with("@@ ") { + let mut parts = line.split_whitespace().skip(1); + let parse = |part: Option<&str>| { + part.and_then(|p| p.get(1..)) + .and_then(|p| p.split(',').next()) + .and_then(|p| p.parse::().ok()) + .ok_or("Invalid GitLab diff hunk") + }; + old = parse(parts.next())?; + new = parse(parts.next())?; + hunk += 1; + } else if hunk > 0 { + let first = line.as_bytes().first().copied(); + let old_line = matches!(first, Some(b' ' | b'-')).then_some(old); + let new_line = matches!(first, Some(b' ' | b'+')).then_some(new); + if (if addition { new_line } else { old_line }) == Some(wanted) { + return Ok(GitLabLine { + old: old_line, + new: new_line, + hunk, + }); + } + if old_line.is_some() { + old = old.saturating_add(1); + } + if new_line.is_some() { + new = new.saturating_add(1); + } + } + } + Err("Selected line is absent from the current GitLab diff".into()) +} + +#[cfg(test)] +mod tests { + use super::super::transport::fixtures::FixtureApi; + use super::*; + const HEAD: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const BASE: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + fn repo(bb: bool) -> HostedRepo { + HostedRepo { + provider: if bb { "bitbucket" } else { "gitlab" }.into(), + host: if bb { "bitbucket.org" } else { "gitlab.com" }.into(), + namespace: "team".into(), + repo: "app".into(), + } + } + fn mr() -> Value { + json!({"iid":7,"title":"Rename","state":"opened","sha":HEAD,"author":{"id":1,"username":"author"},"diff_refs":{"base_sha":BASE,"start_sha":BASE,"head_sha":HEAD}}) + } + #[test] + fn gitlab_ready_handles_title_markers_and_checks_provider_result() { + for title in [ + "Draft: Rename", + "[Draft] Rename", + "(draft) Rename", + "WIP: Rename", + ] { + let mut current = mr(); + current["title"] = json!(title); + let api = FixtureApi::new(vec![ + ("GET", "projects/team%2Fapp/merge_requests/7", Ok(current)), + ( + "PUT", + "projects/team%2Fapp/merge_requests/7", + Ok(json!({"draft":false})), + ), + ]); + repo(false).ready(&api, 7).unwrap(); + assert_eq!(api.writes.borrow()[0].1["title"], "Rename"); + } + let api = FixtureApi::new(vec![( + "GET", + "projects/team%2Fapp/merge_requests/7", + Ok(mr()), + )]); + assert!(repo(false).ready(&api, 7).is_err()); + assert!(api.writes.borrow().is_empty()); + } + fn comment(side: PullRequestDiffSide, start: u32, end: u32) -> PullRequestPendingComment { + PullRequestPendingComment { + path: if side == PullRequestDiffSide::Additions { + "after.txt" + } else { + "before.txt" + } + .into(), + start_line: start, + end_line: end, + side, + body: "Review text".into(), + } + } + fn diffs() -> Vec { + vec![ + json!({"old_path":"before.txt","new_path":"after.txt","diff":"@@ -1,4 +1,5 @@\n same\n-old\n+new\n+another\n context\n tail\n"}), + ] + } + + #[test] + fn gitlab_rename_context_and_ranges_use_version_coordinates() { + let added = gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Additions, 2, 3), + HEAD, + ) + .unwrap(); + assert_eq!(added["position"]["old_path"], "before.txt"); + assert_eq!(added["position"]["new_path"], "after.txt"); + assert_eq!(added["position"]["head_sha"], HEAD); + assert!(added["position"].get("old_line").is_none()); + assert_eq!(added["position"]["line_range"]["start"]["new_line"], 2); + assert_eq!(added["position"]["line_range"]["end"]["new_line"], 3); + let context = gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Additions, 4, 4), + HEAD, + ) + .unwrap(); + assert_eq!(context["position"]["new_line"], 4); + assert_eq!(context["position"]["old_line"], 3); + let deleted = gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Deletions, 2, 2), + HEAD, + ) + .unwrap(); + assert_eq!(deleted["position"]["old_line"], 2); + assert!(deleted["position"].get("new_line").is_none()); + assert!(gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Additions, 99, 99), + HEAD + ) + .is_err()); + assert!(gitlab_inline( + &mr(), + &diffs(), + &comment(PullRequestDiffSide::Additions, 2, 2), + BASE + ) + .is_err()); + } + #[test] + fn bitbucket_coordinates_do_not_conflate_left_and_right_ranges() { + let left = bitbucket_inline(&comment(PullRequestDiffSide::Deletions, 2, 4)); + assert_eq!( + left["inline"], + json!({"path":"before.txt","from":4,"start_from":2}) + ); + let right = bitbucket_inline(&comment(PullRequestDiffSide::Additions, 3, 3)); + assert_eq!(right["inline"], json!({"path":"after.txt","to":3})); + } + #[test] + fn stale_heads_and_terminal_requests_never_write() { + let api = FixtureApi::new(vec![( + "GET", + "projects/team%2Fapp/merge_requests/7", + Ok(mr()), + )]); + assert!(repo(false) + .inline( + &api, + 7, + &comment(PullRequestDiffSide::Additions, 2, 2), + BASE + ) + .is_err()); + assert!(api.writes.borrow().is_empty()); + api.done(); + let api = FixtureApi::new(vec![( + "GET", + "repositories/team/app/pullrequests/7", + Ok(json!({"state":"OPEN","source":{"commit":{"hash":HEAD}}})), + )]); + assert!(repo(true) + .review(&api, 7, PullRequestReviewEvent::Approve, "", &[], BASE) + .is_err()); + assert!(api.writes.borrow().is_empty()); + api.done(); + let api = FixtureApi::new(vec![( + "GET", + "projects/team%2Fapp/merge_requests/7", + Ok(json!({"state":"merged"})), + )]); + assert!(repo(false).add_comment(&api, 7, "hello").is_err()); + assert!(api.writes.borrow().is_empty()); + } + #[test] + fn gitlab_approval_pins_head_and_reports_partial_batch_failure() { + let api = FixtureApi::new(vec![ + ("GET", "projects/team%2Fapp/merge_requests/7", Ok(mr())), + ("GET", "user", Ok(json!({"id":2}))), + ("GET", "projects/team%2Fapp/merge_requests/7", Ok(mr())), + ( + "POST", + "projects/team%2Fapp/merge_requests/7/approve", + Ok(json!({})), + ), + ( + "GET", + "projects/team%2Fapp/merge_requests/7", + Err("head unavailable".into()), + ), + ]); + let error = repo(false) + .review( + &api, + 7, + PullRequestReviewEvent::Approve, + "summary", + &[], + HEAD, + ) + .unwrap_err(); + assert!(error.contains("1 review writes were confirmed")); + assert_eq!(api.writes.borrow()[0].1, json!({"sha":HEAD})); + api.done(); + } + #[test] + fn permission_denial_and_self_review_do_not_write() { + let api = FixtureApi::new(vec![ + ("GET", "projects/team%2Fapp/merge_requests/7", Ok(mr())), + ("GET", "user", Ok(json!({"id":1}))), + ]); + assert!(repo(false) + .review(&api, 7, PullRequestReviewEvent::Approve, "", &[], HEAD) + .unwrap_err() + .contains("own")); + assert!(api.writes.borrow().is_empty()); + let api = FixtureApi::new(vec![( + "GET", + "projects/team%2Fapp", + Ok(json!({"permissions":{"project_access":{"access_level":10}}})), + )]); + let caps = repo(false) + .permissions(&api, &mr(), &json!({"id":2})) + .unwrap(); + assert!(!caps.can_close); + assert!(caps.merge_strategies.is_empty()); + let api = FixtureApi::new(vec![("GET","user/workspaces/team/permissions/repositories?q=repository.full_name%3D%22team%2Fapp%22&pagelen=100",Err("HTTP 403".into()))]); + assert!(repo(true) + .permissions(&api, &json!({"state":"OPEN"}), &json!({"uuid":"me"})) + .is_err()); + api.done(); + } + #[test] + fn bitbucket_merge_is_unavailable_without_atomic_head_guard() { + let api = FixtureApi::new(vec![]); + assert!(repo(true) + .merge(&api, 7, PullRequestMergeStrategy::MergeCommit, HEAD) + .unwrap_err() + .contains("atomically")); + api.done(); + } + #[test] + fn nested_bitbucket_replies_and_gitlab_thread_ranges_survive_normalization() { + let mut pr = PullRequest { + id: 7, + source_commit: HEAD.into(), + ..Default::default() + }; + repo(true).discussions(&mut pr,&[ + json!({"id":1,"inline":{"path":"a","from":4,"start_from":2},"content":{"raw":"root"}}), + json!({"id":2,"parent":{"id":1},"content":{"raw":"reply"}}), + json!({"id":3,"parent":{"id":2},"content":{"raw":"nested reply"}}), + ]); + assert_eq!(pr.comment_count, 3); + assert_eq!(pr.review_threads[0].comments.len(), 3); + assert_eq!(pr.review_threads[0].id, "bitbucket:7:1"); + assert_eq!(pr.review_threads[0].start_line, 2); + let mut pr = PullRequest { + id: 7, + source_commit: HEAD.into(), + ..Default::default() + }; + repo(false).discussions(&mut pr,&[json!({"id":"thread123","notes":[{"id":4,"position":{"new_path":"after.txt","new_line":5,"head_sha":BASE,"line_range":{"start":{"new_line":3}}},"resolvable":true}]})]); + assert_eq!(pr.review_threads[0].start_line, 3); + assert!(pr.review_threads[0].is_outdated); + assert!(!pr.review_threads[0].can_reply); + assert!(repo(true).thread("gitlab:7:123").is_err()); + } +} diff --git a/crates/strand-tauri/src/pull_requests/transport.rs b/crates/strand-tauri/src/pull_requests/transport.rs new file mode 100644 index 00000000..3322e5b2 --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/transport.rs @@ -0,0 +1,460 @@ +//! Host-scoped hosted API transport. Credentials never cross provider origins. +use super::{run_command_input, Result}; +use serde_json::Value; +use std::{collections::HashSet, io::Read, time::Duration}; +use zeroize::Zeroizing; + +pub(crate) fn segment(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()) + .collect::() + .replace('+', "%20") +} + +pub(crate) fn validate_host(host: &str) -> Result<()> { + let url = + url::Url::parse(&format!("https://{host}/")).map_err(|_| "Invalid hosting hostname")?; + if host.is_empty() + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + || host.contains(['/', '\\', '@', '?', '#', '\r', '\n']) + { + return Err( + "Enter a hostname with optional port, without a URL path or credentials".into(), + ); + } + Ok(()) +} + +pub(crate) trait Api { + fn request(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result>; + fn json(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result { + let bytes = self.request(method, endpoint, body)?; + if bytes.is_empty() { + return Ok(Value::Null); + } + serde_json::from_slice(&bytes).map_err(|_| "Provider returned invalid JSON".into()) + } +} + +pub(crate) struct Client<'a> { + pub cwd: &'a str, + pub provider: &'a str, + pub host: &'a str, +} + +impl Api for Client<'_> { + fn request(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result> { + validate_host(self.host)?; + if endpoint.starts_with('/') + || endpoint.contains("://") + || endpoint.contains(['\r', '\n', '\0']) + { + return Err("Invalid provider API endpoint".into()); + } + let input = body + .map(serde_json::to_vec) + .transpose() + .map_err(|e| e.to_string())?; + if self.provider == "bitbucket" { + if self.host != "bitbucket.org" { + return Err("Only Bitbucket Cloud is supported".into()); + } + // API tokens use the API origin and Atlassian account email. Never + // reuse a GitHub/GitLab token or send credentials to a returned link. + let scratch = tempfile::tempdir().map_err(|e| e.to_string())?; + let bytes = Zeroizing::new(run_command_input( + &scratch.path().to_string_lossy(), "git", &["credential", "fill"], + &[("GIT_TERMINAL_PROMPT", "0"), ("GCM_INTERACTIVE", "never")], + Some(b"protocol=https\nhost=api.bitbucket.org\n\n"), + ).map_err(|_| "Configure a Bitbucket API credential for https://api.bitbucket.org in your Git credential helper (Atlassian email and scoped API token)".to_string())?); + let credential = + std::str::from_utf8(&bytes).map_err(|_| "Invalid Bitbucket credential")?; + let username = credential + .lines() + .find_map(|line| line.strip_prefix("username=")) + .ok_or("Bitbucket API credential has no username")?; + let password = credential + .lines() + .find_map(|line| line.strip_prefix("password=")) + .ok_or("Bitbucket API credential has no token")?; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| e.to_string())?; + let mut request = client + .request( + method.parse().map_err(|_| "Invalid HTTP method")?, + format!("https://api.bitbucket.org/2.0/{endpoint}"), + ) + .basic_auth(username, Some(password)) + .header("Accept", "application/json"); + if let Some(input) = input { + request = request + .header("Content-Type", "application/json") + .body(input); + } + let mut response = request + .send() + .map_err(|_| "Bitbucket API request failed; check your connection")?; + // Cloud's PR diff endpoint redirects to the same repository's + // immutable diff route. Follow only that documented read redirect. + if response.status().is_redirection() && method == "GET" && endpoint.ends_with("/diff") + { + let location = response + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or("Bitbucket diff redirect has no location")?; + let location = bitbucket_diff_redirect(location, endpoint)?; + response = client + .get(location) + .basic_auth(username, Some(password)) + .send() + .map_err(|_| "Bitbucket diff request failed")?; + } + if !response.status().is_success() { + return Err(format!("Bitbucket API returned HTTP {}. Check account permissions and token scopes; refresh before retrying a write.", response.status())); + } + let mut bytes = Vec::new(); + response + .take(16 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > 16 * 1024 * 1024 { + return Err("Provider response exceeds 16 MB".into()); + } + return Ok(bytes); + } + let cli = if self.provider == "github" { + "gh" + } else { + "glab" + }; + let mut args = vec!["api", "--hostname", self.host, "--method", method, endpoint]; + if input.is_some() { + args.extend(["--input", "-"]); + } + let bytes = run_command_input( + self.cwd, + cli, + &args, + &[ + ("GH_HOST", self.host), + ("GH_PROMPT_DISABLED", "1"), + ("GITLAB_HOST", self.host), + ("GLAB_CHECK_UPDATE", "false"), + ], + input.as_deref(), + )?; + if bytes.len() > 16 * 1024 * 1024 { + return Err("Provider response exceeds 16 MB".into()); + } + Ok(bytes) + } +} + +/// Fully traverse new adapters' collections. Fail explicitly on malformed, +/// repeated, or oversized pagination instead of reporting a partial set. +pub(crate) fn pages(api: &impl Api, endpoint: &str, bitbucket: bool) -> Result> { + let join = if endpoint.contains('?') { '&' } else { '?' }; + let mut next = format!( + "{endpoint}{join}{}=100", + if bitbucket { "pagelen" } else { "per_page" } + ); + let mut visited = HashSet::new(); + let mut ids = HashSet::new(); + let mut items = Vec::new(); + for page in 1..=500 { + if !visited.insert(next.clone()) { + return Err("Provider repeated a pagination cursor; collection is incomplete".into()); + } + let value = api.json("GET", &next, None)?; + let rows = if bitbucket { + value.get("values") + } else { + Some(&value) + } + .and_then(Value::as_array) + .ok_or("Provider returned an invalid collection")?; + for row in rows { + let id = row + .get("id") + .or_else(|| row.get("uuid")) + .or_else(|| row.get("hash")) + .map(Value::to_string); + if id.is_none() || ids.insert(id.unwrap()) { + items.push(row.clone()); + } + } + if bitbucket { + let Some(link) = value.get("next").and_then(Value::as_str) else { + return Ok(items); + }; + next = bitbucket_next(link, endpoint)?; + } else { + if rows.len() < 100 { + return Ok(items); + } + next = format!("{endpoint}{join}per_page=100&page={}", page + 1); + } + } + Err("Provider collection exceeds 500 pages; narrow the query on the provider website".into()) +} + +fn bitbucket_next(link: &str, endpoint: &str) -> Result { + let url = url::Url::parse(link).map_err(|_| "Invalid Bitbucket pagination link")?; + let original_path = endpoint.split('?').next().unwrap_or_default(); + if url.scheme() != "https" + || url.host_str() != Some("api.bitbucket.org") + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || url.path() != format!("/2.0/{original_path}") + { + return Err("Rejected Bitbucket pagination outside the requested API collection".into()); + } + Ok(format!( + "{}{}", + url.path().trim_start_matches("/2.0/"), + url.query().map(|q| format!("?{q}")).unwrap_or_default() + )) +} + +fn bitbucket_diff_redirect(link: &str, endpoint: &str) -> Result { + let url = url::Url::parse(link).map_err(|_| "Invalid Bitbucket diff redirect")?; + let repo = endpoint + .split("/pullrequests/") + .next() + .ok_or("Invalid Bitbucket diff route")?; + if url.scheme() != "https" + || url.host_str() != Some("api.bitbucket.org") + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || !url.path().starts_with(&format!("/2.0/{repo}/diff/")) + { + return Err("Rejected Bitbucket diff redirect outside this repository".into()); + } + Ok(url) +} + +pub(super) struct GitHubContext<'a> { + pub path: &'a str, + pub host: &'a str, +} + +impl GitHubContext<'_> { + pub fn scope_avatar(&self, avatar: &mut Option) { + if self.host != "github.com" { + if let Some(path) = avatar + .as_ref() + .and_then(|url| url.strip_prefix("https://github.com/")) + { + *avatar = Some(format!("https://{}/{path}", self.host)); + } + } + } + pub fn slug(&self, owner: &str, repo: &str) -> String { + if self.host == "github.com" { + format!("{owner}/{repo}") + } else { + format!("{}/{owner}/{repo}", self.host) + } + } +} + +pub(super) fn github_command( + cwd: &GitHubContext<'_>, + program: &str, + args: &[&str], + envs: &[(&str, &str)], +) -> Result> { + github_command_input(cwd, program, args, envs, None) +} + +pub(super) fn github_command_input( + cwd: &GitHubContext<'_>, + program: &str, + args: &[&str], + envs: &[(&str, &str)], + input: Option<&[u8]>, +) -> Result> { + let mut scoped = envs.to_vec(); + scoped.push(("GH_HOST", cwd.host)); + // Explicit --hostname also scopes GraphQL IDs, viewer identity and REST. + let mut args = args.to_vec(); + if program == "gh" && args.first() == Some(&"api") { + args.extend(["--hostname", cwd.host]); + } + run_command_input(cwd.path, program, &args, &scoped, input) +} + +#[cfg(test)] +pub(crate) mod fixtures { + use super::*; + use std::{cell::RefCell, collections::VecDeque}; + pub struct FixtureApi { + steps: RefCell)>>, + pub writes: RefCell>, + } + impl FixtureApi { + pub fn new(steps: Vec<(&str, &str, Result)>) -> Self { + Self { + steps: RefCell::new( + steps + .into_iter() + .map(|(m, e, v)| (m.into(), e.into(), v)) + .collect(), + ), + writes: RefCell::new(vec![]), + } + } + pub fn done(&self) { + assert!( + self.steps.borrow().is_empty(), + "Unconsumed fixture requests: {:?}", + self.steps.borrow() + ); + } + } + impl Api for FixtureApi { + fn request(&self, method: &str, endpoint: &str, body: Option<&Value>) -> Result> { + let (m, e, response) = self + .steps + .borrow_mut() + .pop_front() + .expect("Unexpected API request"); + assert_eq!((method, endpoint), (m.as_str(), e.as_str())); + if method != "GET" { + self.writes + .borrow_mut() + .push((endpoint.into(), body.cloned().unwrap_or(Value::Null))); + } + response.map(|v| serde_json::to_vec(&v).unwrap()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fixtures::FixtureApi; + use serde_json::json; + #[test] + fn github_identity_preserves_public_follows_and_separates_custom_hosts() { + let public = GitHubContext { + path: ".", + host: "github.com", + }; + let enterprise = GitHubContext { + path: ".", + host: "git.example:8443", + }; + assert_eq!(public.slug("team", "app"), "team/app"); + assert_eq!(enterprise.slug("team", "app"), "git.example:8443/team/app"); + let mut avatar = Some("https://github.com/reviewer.png?size=80".into()); + enterprise.scope_avatar(&mut avatar); + assert_eq!( + avatar.as_deref(), + Some("https://git.example:8443/reviewer.png?size=80") + ); + } + #[test] + fn gitlab_paginates_and_deduplicates_101_entries() { + let first = (1..=100).map(|id| json!({"id":id})).collect::>(); + let api = FixtureApi::new(vec![ + ( + "GET", + "projects/a%2Fb/merge_requests?per_page=100", + Ok(json!(first)), + ), + ( + "GET", + "projects/a%2Fb/merge_requests?per_page=100&page=2", + Ok(json!([{"id":100},{"id":101}])), + ), + ]); + assert_eq!( + pages(&api, "projects/a%2Fb/merge_requests", false) + .unwrap() + .len(), + 101 + ); + api.done(); + } + #[test] + fn bitbucket_follows_opaque_next_even_on_short_pages() { + let api = FixtureApi::new(vec![ + ( + "GET", + "repositories/a/b/pullrequests?pagelen=100", + Ok( + json!({"values":[{"id":1}],"next":"https://api.bitbucket.org/2.0/repositories/a/b/pullrequests?cursor=opaque"}), + ), + ), + ( + "GET", + "repositories/a/b/pullrequests?cursor=opaque", + Ok(json!({"values":[{"id":1},{"id":2}]})), + ), + ]); + assert_eq!( + pages(&api, "repositories/a/b/pullrequests", true) + .unwrap() + .len(), + 2 + ); + api.done(); + } + #[test] + fn pagination_fails_on_permission_errors_and_foreign_cursors() { + let api = FixtureApi::new(vec![( + "GET", + "user/workspaces?pagelen=100", + Err("HTTP 403".into()), + )]); + assert!(pages(&api, "user/workspaces", true) + .unwrap_err() + .contains("403")); + for link in [ + "https://evil.test/2.0/user/workspaces?page=2", + "https://api.bitbucket.org/2.0/user?page=2", + "http://api.bitbucket.org/2.0/user/workspaces", + "https://token@api.bitbucket.org/2.0/user/workspaces", + ] { + assert!(bitbucket_next(link, "user/workspaces").is_err()); + } + assert!(bitbucket_diff_redirect( + "https://api.bitbucket.org/2.0/repositories/a/b/diff/head..base", + "repositories/a/b/pullrequests/1/diff" + ) + .is_ok()); + assert!(bitbucket_diff_redirect( + "https://api.bitbucket.org/2.0/repositories/other/b/diff/head", + "repositories/a/b/pullrequests/1/diff" + ) + .is_err()); + } + #[test] + fn hostname_rejects_credentials_paths_and_query_strings() { + for host in [ + "https://git.example", + "token@git.example", + "git.example/api", + "git.example?x=1", + "", + "git.example\\x", + ] { + assert!(validate_host(host).is_err(), "{host}"); + } + assert!(validate_host("git.example:8443").is_ok()); + assert_eq!(segment("nested/team x/repo"), "nested%2Fteam%20x%2Frepo"); + } +} diff --git a/docs/hosted-provider-contracts.md b/docs/hosted-provider-contracts.md new file mode 100644 index 00000000..e3c60d04 --- /dev/null +++ b/docs/hosted-provider-contracts.md @@ -0,0 +1,72 @@ +# Hosted provider contracts + +Implemented September 6, 2026 in `pull_requests/hosted.rs` and +`pull_requests/transport.rs`. GitHub and Azure retain their existing adapters; +custom GitHub hosts pass through `GitHubContext` with an explicit hostname. + +## API and authentication references + +- GitHub: [CLI API hostname](https://cli.github.com/manual/gh_api), + [host and token environment](https://cli.github.com/manual/gh_help_environment), + [custom API host configuration](https://cli.github.com/manual/gh_config_set). +- GitLab: [CLI API](https://docs.gitlab.com/cli/api/), + [merge requests](https://docs.gitlab.com/api/merge_requests/), + [discussions and diff coordinates](https://docs.gitlab.com/api/discussions/), + [approval SHA](https://docs.gitlab.com/api/merge_request_approvals/). +- Bitbucket Cloud: [pull requests](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/), + [workspace repository permissions](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/), + [workspaces](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/), + [API token permissions](https://support.atlassian.com/bitbucket-cloud/docs/api-token-permissions/). + +GitLab uses the authenticated CLI host and nested project path. GitHub REST, +GraphQL, PR commands and viewer identity use the same explicit host; public +GitHub repository labels retain their earlier owner/repo form. Bitbucket uses +only Cloud's API origin and the system Git credential helper. The helper runs +from a neutral directory, with prompting disabled; credentials are never +included in provider error messages or saved application state. + +## Capabilities and consistency + +GitLab project/group access and Bitbucket's per-workspace repository permissions +inform controls. Permission-query failure is explicit. The provider remains +authoritative for protected branches and token scopes. Pipeline/status results +are not advertised as complete policy evaluations. Shallow list items carry +no optimistic write capabilities before detail loads. + +GitLab merge and approval send the reviewed SHA. Inline discussions include +base/start/head coordinates, rename paths, both sides for context lines, and +versioned range line codes. Bitbucket merge stays disabled because its merge +contract has no atomic expected-head condition. Cloud comments and review +decisions check before/after writing and report races as possibly posted. +Neither adapter claims atomic batch reviews: errors preserve the local draft +and report confirmed writes for reconciliation before retry. + +Bitbucket Server, Cloud merge/reopen/draft transitions/discussion resolution, +GitLab request-changes, and editing/resetting these providers' submitted reviews +remain provider-site actions. Same-repository Bitbucket checkout is supported; +fork checkout requires opening the source fork. These are capability limits, +not silent fallback to another provider. + +Collections traverse GitLab pages and Bitbucket opaque `next` links, including +short intermediate pages, with duplicate and loop protection. Cross-origin or +cross-collection links are rejected. Cloud's documented PR-diff redirect is +allowed only to that repository's API diff route. Limits produce explicit +incomplete-result errors. Selected detail fetches commits/discussions; patches +load on Code; activity polling never fetches patches or commit history. + +## Verification + +The Rust fixtures exercise 101-entry pagination, opaque short pages, permission +denial, custom-host coordinates and identities, rename/context/range comments, +terminal and stale-head rejection, approval SHA, partial review failures and +provider draft markers. Existing GitHub/Azure fixture tests remain in the same +suite. Frontend tests cover provider labels and merge capabilities while +retaining existing GitHub/Azure behavior. + +The isolated Windows Tauri/WebView2 pass exercised GitLab Code rendering, +retained review text after a stale-head rejection, disabled request-changes, +Bitbucket's provider-site merge control, keyboard palette access and real +per-remote settings persistence in a scratch repository. Hosted responses and +writes were injected fixtures; no authenticated live-provider mutation was +performed. The app used a separate identity/profile and temporary embedded +debug configuration; the normal binary was rebuilt after verification. diff --git a/docs/learnings.md b/docs/learnings.md index e50db001..830b32bf 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2494,3 +2494,14 @@ Pierre reads `navigator.userAgent` during module evaluation; Node 22's built-in `navigator` hid a failure on CI's Node 20. Stub browser globals and restore them after the test, while retaining real integration assertions. Reproduce this class of failure locally with `--no-experimental-global-navigator`. + +**Hosted provider writes preserve host and commit scope (2026-09-06).** +Custom GitHub remotes need an explicit adapter and host-scoped CLI calls; keep +GitHub.com's existing owner/repo identity so saved follows and drafts survive. +GitLab inline coordinates use the diff version's base/start/head and both +paths across a rename. A preflight head read is not an atomic merge guard: +Bitbucket Cloud merge stays unavailable until its API accepts an expected +head. Cloud comment/review races must report that a write may have happened, +and partial batches must retain drafts with reconciliation guidance. Never +reuse another provider's credentials; Cloud API tokens are scoped to +api.bitbucket.org through the system Git credential helper. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ea335854..973cc417 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1947,6 +1947,7 @@ export function App() { { id: 'settings', label: 'Settings…', group: 'Actions', shortcut: keyHint('settings'), keywords: 'preferences shortcuts keyboard config options', run: () => openSettingsAt('appearance') }, { id: 'keybindings', label: 'Settings: Keyboard shortcuts', group: 'Actions', keywords: 'keyboard shortcuts keybindings rebind configure customize', run: () => openSettingsAt('keyboard') }, { id: 'settings-ai', label: 'Settings: AI', group: 'Actions', keywords: 'ai chatgpt codex claude commit message suggest login', run: () => openSettingsAt('ai') }, + { id: 'settings-hosting', label: 'Settings: Hosting', group: 'Actions', keywords: 'github enterprise custom host gitlab bitbucket azure provider authentication account', run: () => openSettingsAt('hosting') }, { id: 'settings-plugins', label: 'Settings: Plugins', group: 'Actions', keywords: 'plugins marketplace extensions workbench surfaces install', run: () => openSettingsAt('plugins') }, { id: 'heroi-new-conversation', label: 'Heroi: New conversation', group: 'Actions', keywords: 'heroi agent chat claude codex cursor', run: () => window.dispatchEvent(new CustomEvent(HEROI_NEW_CONVERSATION_EVENT)) }, { diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 4298b70f..9c51c7b0 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -82,6 +82,8 @@ export const handlers: Record = { azdo_profile_set_pat: () => unavailable('Azure DevOps profiles'), azdo_profile_clear_pat: () => unavailable('Azure DevOps profiles'), azdo_profile_test: () => unavailable('Azure DevOps profiles'), + repo_hosting_providers: () => [], + repo_set_hosting_provider: () => unavailable('Hosted repository setup'), hosting_connection_status: () => ({ github: { installed: true, connected: true, account: 'dana', detail: 'gh 2.62.0 · signed in as dana (demo)' }, azure_dev_ops: { installed: false, connected: false, account: null, detail: 'az CLI not installed' }, diff --git a/ui/src/lib/hostingCapabilities.test.ts b/ui/src/lib/hostingCapabilities.test.ts new file mode 100644 index 00000000..2b0250af --- /dev/null +++ b/ui/src/lib/hostingCapabilities.test.ts @@ -0,0 +1,37 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterAll, describe, expect, it } from 'vitest'; +import type { PullRequest } from './types'; + +const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); +if (typeof navigator === 'undefined') Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'vitest', platform: '', maxTouchPoints: 0 } }); +afterAll(() => { + if (navigatorDescriptor) Object.defineProperty(globalThis, 'navigator', navigatorDescriptor); + else Reflect.deleteProperty(globalThis, 'navigator'); +}); +const { PullRequestMergeControl } = await import('../views/PullRequestMergeControl'); +const { providerName } = await import('./pullRequests'); +const pr = { id: 7, state: 'open', is_draft: false, can_mark_ready: false, source_commit: 'a'.repeat(40) } as PullRequest; +const caps = { can_comment: true, can_review: true, can_request_changes: false, can_close: true, can_reopen: false, merge_strategies: [] }; + +describe('hosted provider capabilities', () => { + it('keeps Bitbucket merge on the provider when atomic head protection is unavailable', () => { + const html = renderToStaticMarkup(createElement(PullRequestMergeControl, { path: '/fixture', provider: 'bitbucket', pr: { ...pr, capabilities: caps }, disabledReason: '', onMerged: () => {}, onToast: () => {} })); + expect(html).toContain('Merge on Bitbucket Cloud'); + expect(html).not.toContain(' { + const html = renderToStaticMarkup(createElement(PullRequestMergeControl, { path: '/fixture', provider: 'git_lab', pr: { ...pr, capabilities: { ...caps, merge_strategies: ['merge_commit'] } }, disabledReason: '', onMerged: () => {}, onToast: () => {} })); + expect(html).toContain('Merge with project settings'); + expect(html).not.toContain('Create a merge commit'); + }); + it('retains existing provider behavior when optional capabilities are absent', () => { + for (const provider of ['git_hub', 'azure_dev_ops'] as const) { + const html = renderToStaticMarkup(createElement(PullRequestMergeControl, { path: '/fixture', provider, pr, disabledReason: '', onMerged: () => {}, onToast: () => {} })); + expect(html).toContain('Merge pull request'); + expect(html).toContain('Choose merge strategy'); + } + expect(providerName('git_lab')).toBe('GitLab'); + expect(providerName('bitbucket')).toBe('Bitbucket Cloud'); + }); +}); diff --git a/ui/src/lib/pullRequests.ts b/ui/src/lib/pullRequests.ts index 11cd68ea..7a51dd7b 100644 --- a/ui/src/lib/pullRequests.ts +++ b/ui/src/lib/pullRequests.ts @@ -398,3 +398,7 @@ export function pullRequestForBranch( return source === current && (state === 'open' || state === 'active'); }) ?? null; } + +export function providerName(provider: PullRequestProvider): string { + return { git_hub: 'GitHub', azure_dev_ops: 'Azure DevOps', git_lab: 'GitLab', bitbucket: 'Bitbucket Cloud' }[provider]; +} diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index e7356543..4dcd3c19 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -1,3 +1,4 @@ +import type { RemoteHostingProvider } from './types'; import { Channel, invoke } from '@tauri-apps/api/core'; import type { @@ -126,6 +127,8 @@ export const tauri = { createInitialCommit: boolean, ) => invoke('repo_init', { path, initialBranch, gitignore, createInitialCommit }), azdoHelperStatus: () => invoke('azdo_helper_status'), + repoHostingProviders: (path: string) => invoke('repo_hosting_providers', { path }), + repoSetHostingProvider: (path: string, remote: string, provider: string) => invoke('repo_set_hosting_provider', { path, remote, provider }), hostingConnectionStatus: () => invoke('hosting_connection_status'), azdoHelperEnable: () => invoke('azdo_helper_enable'), azdoHelperDisable: () => invoke('azdo_helper_disable'), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c27be6a0..8d4999a9 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -255,7 +255,7 @@ export interface HostingConnectionStatus { azure_dev_ops: ProviderConnectionStatus; } -export type PullRequestProvider = 'git_hub' | 'azure_dev_ops'; +export type PullRequestProvider = 'git_hub' | 'azure_dev_ops' | 'git_lab' | 'bitbucket'; export type PullRequestMergeStrategy = 'merge_commit' | 'squash' | 'rebase'; export type PullRequestLifecycleAction = 'close' | 'reopen'; export type PullRequestReviewEvent = 'comment' | 'approve' | 'request_changes'; @@ -338,6 +338,7 @@ export interface PullRequestReview { } export interface PullRequest { + capabilities?: { can_comment: boolean; can_review: boolean; can_request_changes: boolean; can_close: boolean; can_reopen: boolean; merge_strategies: PullRequestMergeStrategy[] }; id: number; title: string; state: string; @@ -927,3 +928,5 @@ export type AiGenerationOutcome = coverage: AiInputCoverage; provider: AiProvider; }; + +export interface RemoteHostingProvider { remote: string; url: string; provider: string } diff --git a/ui/src/views/PullRequestCreateDialog.tsx b/ui/src/views/PullRequestCreateDialog.tsx index 9809ec6a..67a863d3 100644 --- a/ui/src/views/PullRequestCreateDialog.tsx +++ b/ui/src/views/PullRequestCreateDialog.tsx @@ -1,3 +1,4 @@ +import { providerName } from '../lib/pullRequests'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Dialog } from '../components/Dialog'; @@ -215,7 +216,7 @@ export function PullRequestCreateDialog({ void fillWithAi(); }, [autoFill, targetBranch]); - const providerLabel = provider === 'git_hub' ? 'GitHub' : 'Azure DevOps'; + const providerLabel = providerName(provider); const aiProviderLabel = aiProvider === 'openai' ? 'Codex' : 'Claude Code'; const fieldsDisabled = busy || suggesting; const aiActionLabel = title.trim() || description.trim() ? 'Replace' : 'Fill'; diff --git a/ui/src/views/PullRequestMergeControl.tsx b/ui/src/views/PullRequestMergeControl.tsx index 19ab63e2..970cfd1e 100644 --- a/ui/src/views/PullRequestMergeControl.tsx +++ b/ui/src/views/PullRequestMergeControl.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Icon } from '../components/Icon'; -import { canMarkPullRequestReady } from '../lib/pullRequests'; +import { canMarkPullRequestReady, providerName } from '../lib/pullRequests'; import { errMessage, tauri } from '../lib/tauri'; import type { PullRequest, PullRequestMergeStrategy, PullRequestProvider } from '../lib/types'; @@ -31,9 +31,6 @@ const STRATEGIES: { }, ]; -const providerName = (provider: PullRequestProvider) => - provider === 'git_hub' ? 'GitHub' : 'Azure DevOps'; - export function PullRequestMergeControl({ path, provider, @@ -49,6 +46,10 @@ export function PullRequestMergeControl({ onMerged: (next: PullRequest) => void; onToast: (message: string, kind?: 'success' | 'error') => void; }) { + const strategies = STRATEGIES.filter((item) => !pr.capabilities || pr.capabilities.merge_strategies.includes(item.value)) + .map((item) => provider === 'git_lab' && item.value === 'merge_commit' + ? { ...item, buttonLabel: 'Merge with project settings', menuLabel: 'Use project merge method', hint: 'GitLab applies the project’s merge method and protections.' } + : item); const [strategy, setStrategy] = useState('merge_commit'); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); @@ -57,10 +58,10 @@ export function PullRequestMergeControl({ const toggleRef = useRef(null); const optionRefs = useRef>([]); const mountedRef = useRef(true); - const selectedIndex = STRATEGIES.findIndex((item) => item.value === strategy); - const selected = STRATEGIES[selectedIndex]; + const selectedIndex = Math.max(0, strategies.findIndex((item) => item.value === strategy)); + const selected = strategies[selectedIndex] ?? strategies[0] ?? STRATEGIES[0]; const markReady = canMarkPullRequestReady(pr); - const disabled = (markReady ? false : Boolean(disabledReason)) || busy; + const disabled = (markReady ? false : (Boolean(disabledReason) || strategies.length === 0)) || busy; useEffect(() => { mountedRef.current = true; @@ -102,7 +103,7 @@ export function PullRequestMergeControl({ }; const moveMenuFocus = (index: number) => { - const wrapped = (index + STRATEGIES.length) % STRATEGIES.length; + const wrapped = (index + strategies.length) % strategies.length; optionRefs.current[wrapped]?.focus(); }; @@ -112,7 +113,7 @@ export function PullRequestMergeControl({ setBusy(true); setError(null); try { - await tauri.repoPullRequestMerge(path, pr.id, strategy, pr.source_commit); + await tauri.repoPullRequestMerge(path, pr.id, selected.value, pr.source_commit); let next: PullRequest; try { next = await tauri.repoPullRequest(path, pr.id); @@ -168,6 +169,7 @@ export function PullRequestMergeControl({ return () => window.removeEventListener('strand:pull-request-ready', onReadyRequest); }, [submitReady]); + if (!markReady && strategies.length === 0) return Merge on {providerName(provider)}; if (markReady) { return (
@@ -232,12 +234,12 @@ export function PullRequestMergeControl({ if (event.key === 'ArrowDown') { event.preventDefault(); moveMenuFocus(current + 1); } else if (event.key === 'ArrowUp') { event.preventDefault(); moveMenuFocus(current - 1); } else if (event.key === 'Home') { event.preventDefault(); moveMenuFocus(0); } - else if (event.key === 'End') { event.preventDefault(); moveMenuFocus(STRATEGIES.length - 1); } + else if (event.key === 'End') { event.preventDefault(); moveMenuFocus(strategies.length - 1); } else if (event.key === 'Escape') { event.preventDefault(); closeMenu(true); } else if (event.key === 'Tab') setOpen(false); }} > - {STRATEGIES.map((item, index) => ( + {strategies.map((item, index) => (
- {isOpenPullRequest(pr) ? ( + {isOpenPullRequest(pr) && (pr.capabilities?.can_comment ?? true) ? ( ) : (

This pull request is {displayState(pr)}. Its timeline is read-only in Strand.

@@ -1020,7 +1019,7 @@ function PullRequestChanges({ () => unresolvedThreadTargets(allTreePaths, pr.review_threads ?? []), [allTreePaths, pr.review_threads], ); - const openForReview = pr.state === 'open' || pr.state === 'active'; + const openForReview = (pr.capabilities?.can_comment ?? true) && (pr.state === 'open' || pr.state === 'active'); useEffect(() => { if (navigationTarget && filesByPath.has(navigationTarget.path) && !treePaths.includes(navigationTarget.path)) { @@ -1528,8 +1527,8 @@ function PullRequestChanges({
{reviewDraft.body.length.toLocaleString()} / 65,536 - - + +
)} @@ -1846,9 +1845,9 @@ function PullRequestDetails({ : !pr.source_commit ? 'Refresh this pull request before merging' : ''; - const lifecycleAction = isOpenPullRequest(pr) + const lifecycleAction = isOpenPullRequest(pr) && (pr.capabilities?.can_close ?? true) ? 'close' - : isReopenablePullRequest(pr) + : isReopenablePullRequest(pr) && (pr.capabilities?.can_reopen ?? true) ? 'reopen' : null; useEffect(() => { diff --git a/ui/src/views/settings/HostingSection.tsx b/ui/src/views/settings/HostingSection.tsx index 4f8b044d..27ee373b 100644 --- a/ui/src/views/settings/HostingSection.tsx +++ b/ui/src/views/settings/HostingSection.tsx @@ -13,6 +13,7 @@ import type { ProviderConnectionStatus, } from '../../lib/types'; import { useRepo } from '../../stores/repo'; +import { RemoteProviderSettings } from './RemoteProviderSettings'; const emptyStatus: AzdoHelperStatus = { enabled: false, @@ -193,13 +194,14 @@ export function HostingSection() {
Hosting connections -

Strand uses each provider’s existing CLI authentication.

+

Strand uses provider CLIs or your system credential helper for authentication.

+
s.activePath); + const [remotes, setRemotes] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const generation = useRef(0); + useEffect(() => { + let active = true; + generation.current += 1; + setRemotes([]); setError(null); setBusy(false); + if (path) void tauri.repoHostingProviders(path).then((rows) => { if (active) setRemotes(rows); }).catch((e) => { if (active) setError(errMessage(e)); }); + return () => { active = false; generation.current += 1; }; + }, [path]); + async function save(remote: string, provider: string) { + if (!path || busy) return; + const current = generation.current; + setBusy(true); setError(null); + try { await tauri.repoSetHostingProvider(path, remote, provider); if (generation.current === current) setRemotes((rows) => rows.map((r) => r.remote === remote ? { ...r, provider } : r)); } + catch (e) { if (generation.current === current) setError(errMessage(e)); } + finally { if (generation.current === current) setBusy(false); } + } + return
+

GitLab, Bitbucket Cloud and custom GitHub hosts

+

GitLab uses glab auth login --hostname HOST. Bitbucket Cloud uses an API credential for api.bitbucket.org from your Git credential helper (Atlassian email and scoped API token). GitHub Enterprise uses gh auth login --hostname HOST; custom API routing stays in gh configuration.

+

Public hosts are detected automatically. For custom hosts, choose the adapter for each remote in the active repository, then refresh Pull Requests. Azure Server profiles continue to use the setup below.

+ {remotes.map((r) => )} + {error &&

{error}

} +
; +} diff --git a/website/docs/pull-requests.md b/website/docs/pull-requests.md index 26b2d8fe..82f43881 100644 --- a/website/docs/pull-requests.md +++ b/website/docs/pull-requests.md @@ -1,7 +1,7 @@ # Pull Requests The **Pull Requests** sidebar destination shows hosted pull requests for the -active repository. Strand currently supports GitHub and Azure DevOps, detected +active repository. Strand supports GitHub, GitLab, Bitbucket Cloud and Azure DevOps, detected from the repository's remotes; `origin` wins when more than one supported remote exists. Open the same view from the command palette with "Show: Pull Requests". @@ -11,10 +11,20 @@ GitHub and Azure DevOps Services delegate authentication to the provider's official CLI, and Strand never reads or stores those access tokens: - GitHub requires [GitHub CLI](https://cli.github.com/) and `gh auth login`. +- GitLab requires [glab](https://docs.gitlab.com/cli/) and + `glab auth login --hostname HOST` (use `gitlab.com` for the public service). +- Bitbucket Cloud uses your system Git credential helper's HTTPS credential + for `api.bitbucket.org`: Atlassian account email and a scoped API token. + This API credential is separate from Git clone/push authentication. - Azure DevOps requires Azure CLI, the `azure-devops` extension, and `az login`. -Settings → Hosting summarizes both CLI connections and displays the signed-in -account reported by each provider. +Settings → Hosting summarizes GitHub and Azure CLI connections. Its custom-host +section provides GitLab/Bitbucket setup and a provider selector for each remote. +Public hosts are automatic; select GitHub / Enterprise or GitLab for custom +hosts and refresh Pull Requests. GitHub Enterprise uses +`gh auth login --hostname HOST`; custom API routing follows `gh` configuration. +Hostnames are part of custom-host review identities, so drafts and follows do +not collide with the same repository name on another server. Azure DevOps Server 2020+ uses the optional `strand-azdo` REST helper instead of `az`. Enable it and add an HTTPS collection profile under **Settings → @@ -40,7 +50,7 @@ shows the provider error and the setup command. Provider calls time out after Choose **Create PR** in the Pull Requests toolbar, or run “Pull Requests: create for current branch…” from the command palette. The dialog creates a -GitHub or Azure DevOps pull request from the checked-out branch with a title, +pull request or GitLab merge request from the checked-out branch with a title, Markdown description, target branch, and optional draft state. After creation, Strand opens the new PR and follows it automatically. @@ -316,5 +326,32 @@ available while any PR is active, and the update command appears only for an open GitHub PR; both contextual commands disappear on the inbox. Suggestions and richer Azure policy details are planned but are not presented -as available yet. GitLab and -Bitbucket adapters will use the same workspace in a later slice. +as available yet. + +## GitLab and Bitbucket Cloud + +Both adapters page their lists, commits and discussions to completion; a failed +page is an error rather than a complete-looking partial result. Rich detail +loads on selection and patches load only on Code. Activity refreshes do not +download patches or commit history. Pipeline/commit status alone is not treated +as a complete view of provider merge policies. + +GitLab supports comments, approvals, inline replies and resolution, close/reopen, +mark-ready and merging with the project's merge method or squash. Inline +comments preserve diff-version base/start/head commits and renamed-file paths. +Approval and merge include the reviewed SHA. Request changes remains available +on GitLab's website. + +Bitbucket Cloud supports comments, inline replies, approval, +request-changes and closing a request. Merge, discussion resolution, reopening and draft transitions +remain provider-site actions. Cloud's merge API cannot atomically guard the +reviewed head, so Strand does not offer that write. Inline comments and review +decisions recheck the head before and after writing; the API cannot pin them +atomically. If the head changes during a write, inspect the posted result before +retrying. Fork checkout also requires opening the source fork locally. +Bitbucket Server is outside this adapter's scope. + +GitLab and Bitbucket review batches consist of separate API writes. A failure +retains the draft and reports confirmed progress: refresh and reconcile posted +items before retrying to avoid duplicates. Provider permissions remain +authoritative even when a control was enabled by the last refresh. diff --git a/website/docs/settings.md b/website/docs/settings.md index c3aa8e84..0cdb67d7 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -48,6 +48,28 @@ Everything else about git — credentials, SSH keys, commit signing — is inher ## Hosting +The **GitLab, Bitbucket Cloud and custom GitHub hosts** section provides setup +instructions and lists the active repository's remotes. For a custom host, +select **GitHub / Enterprise** or **GitLab**, then refresh Pull Requests. +**Automatic** restores public-host detection. The selection saves immediately +in this repository's Git configuration and does not change the remote URL. +The palette command **Settings: Hosting** opens this section directly. + +Sign in to GitLab with `glab auth login --hostname HOST`, or to GitHub +Enterprise with `gh auth login --hostname HOST`. API routing and authentication +stay scoped to that hostname in the CLI. GitHub's custom API host configuration +is honored by `gh`. + +Bitbucket Cloud reads an API-only HTTPS credential for `api.bitbucket.org` +from the system Git credential helper. Use your Atlassian account email as +username and a scoped API token as password. Supply read scopes for user, +workspace, repository and pull-request data; add repository and pull-request +write scopes for creation and review writes. Scopes do not grant repository +membership or override branch restrictions. See +[Bitbucket API token permissions](https://support.atlassian.com/bitbucket-cloud/docs/api-token-permissions/). +Git clone/push authentication remains separate. Strand does not save this token +in its settings or recovery records. + Hosting is organized into GitHub, Azure DevOps, and Azure DevOps Server accordions. Each summary keeps its connection state visible while the details are collapsed. GitHub shows the account returned by `gh`; Azure DevOps shows From b9a7c686f13708774f9555c807f7255996076250 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 16:55:55 +0200 Subject: [PATCH 2/3] feat(hosting): add resumable repository publication with explicit initial push --- README.md | 4 + ROADMAP.md | 6 + TASKS.md | 6 +- crates/strand-tauri/src/commands.rs | 20 + crates/strand-tauri/src/main.rs | 5 + crates/strand-tauri/src/pull_requests.rs | 1 + .../strand-tauri/src/pull_requests/publish.rs | 760 ++++++++++++++++++ docs/hosted-provider-contracts.md | 22 + docs/learnings.md | 8 + ui/src/App.tsx | 9 + ui/src/components/Sidebar.tsx | 1 + ui/src/demo/dispatch.ts | 5 + ui/src/lib/tauri.ts | 7 +- ui/src/lib/types.ts | 8 + ui/src/styles/features.css | 4 + ui/src/views/PublishRepoDialog.tsx | 116 +++ website/docs/repositories-and-workspaces.md | 28 + 17 files changed, 1007 insertions(+), 3 deletions(-) create mode 100644 crates/strand-tauri/src/pull_requests/publish.rs create mode 100644 ui/src/views/PublishRepoDialog.tsx diff --git a/README.md b/README.md index f37f300a..c7e4befe 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ the resolved app appearance automatically. GitLab merges guard the reviewed head and follow project settings. Bitbucket merge, GitLab request-changes and Bitbucket draft transitions remain provider-site actions. Bitbucket Server is not supported. +- **Publish repository** — create an empty GitHub/Enterprise, GitLab or Bitbucket + Cloud repository after reviewing its account, destination and visibility. + Add the remote, then explicitly choose whether to push the reviewed commit. + Interrupted creation and remote setup can be resumed from the same dialog. - **Responsive refreshes** — repository updates coalesce during bursts of agent edits, hidden diff panes load patches when opened, and Files reuses its inventory until paths or ignore rules change. Workspace scans run with diff --git a/ROADMAP.md b/ROADMAP.md index e7220f04..e53ba22f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2820,6 +2820,12 @@ authentication and per-remote adapter selection. GitHub.com and Azure routing remain intact. Bitbucket merge stays on the provider because its API cannot atomically guard the reviewed head. +**Hosted repository publication shipped (2026-09-06):** Publish repository +reviews an authenticated GitHub/Enterprise, GitLab or Bitbucket Cloud destination, +creates an empty repository, adds its remote, then separately offers an explicit +push of the reviewed commit. A local recovery record survives partial failures; +uncertain creation checks the destination without repeating the create request. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index d2b839c0..885c767f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -117,9 +117,11 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- - ☐ **F10 / P2 — Guided bisect.** Good/bad/skip, operation progress, external session resume and safe reset to the original checkout; defer test-command execution until the manual workflow is complete. -- ☐ **F14 / P2 — Publish a new hosted repository.** Provider/account/visibility +- ☑ **F14 / P2 — Publish a new hosted repository.** Provider/account/visibility selection, concrete destination review, remote configuration and explicit - initial push, with recovery from partial failure. + initial push, with recovery from partial failure (GitHub/Enterprise, GitLab + and Bitbucket Cloud; `PublishRepoDialog`, `hosted_publish_*`, persisted + recovery stages and exact-reviewed-commit push). - ☐ **F15 / P2 — User-defined repository/ref/file actions.** Safe executable/ argv templates, exact context, palette/menu discovery, preview, bounded output and cancellation; editor/terminal templates and internal registries already exist. diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 818cd028..b61234c1 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -2157,3 +2157,23 @@ pub async fn repo_hosting_providers(path: String) -> CmdResult CmdResult<()> { run_blocking("configure remote provider", move || pull_requests::set_hosting_provider(&path, &remote, &provider).map_err(|message| CmdError { message })).await } +#[tauri::command(async)] +pub async fn hosted_publish_accounts(path: String, provider: String, host: String) -> CmdResult { + run_blocking("publish destinations", move || pull_requests::publish::accounts(&path, &provider, &host).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_state(path: String) -> CmdResult> { + run_blocking("publish recovery", move || pull_requests::publish::state(&path).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_preview(path: String, request: pull_requests::publish::PublishRequest) -> CmdResult { + run_blocking("review publish destination", move || pull_requests::publish::preview(&path, request).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_advance(path: String, id: String, action: String) -> CmdResult { + run_blocking("publish repository", move || pull_requests::publish::advance(&path, &id, &action).map_err(|message| CmdError { message })).await +} +#[tauri::command(async)] +pub async fn hosted_publish_forget(path: String) -> CmdResult<()> { + run_blocking("dismiss publish recovery", move || pull_requests::publish::forget(&path).map_err(|message| CmdError { message })).await +} diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 239dc216..0ee5ef43 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -179,6 +179,11 @@ fn main() { commands::hosting_connection_status, commands::repo_hosting_providers, commands::repo_set_hosting_provider, + commands::hosted_publish_accounts, + commands::hosted_publish_state, + commands::hosted_publish_preview, + commands::hosted_publish_advance, + commands::hosted_publish_forget, commands::azdo_helper_enable, commands::azdo_helper_disable, commands::azdo_helper_remove, diff --git a/crates/strand-tauri/src/pull_requests.rs b/crates/strand-tauri/src/pull_requests.rs index a122e24b..90a5b88b 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -5,6 +5,7 @@ //! loads nested metadata only for the selected pull request so provider query //! limits and large repositories remain predictable. mod hosted; +pub(crate) mod publish; pub(crate) mod transport; use transport::{github_command, github_command_input, GitHubContext}; diff --git a/crates/strand-tauri/src/pull_requests/publish.rs b/crates/strand-tauri/src/pull_requests/publish.rs new file mode 100644 index 00000000..acd15c12 --- /dev/null +++ b/crates/strand-tauri/src/pull_requests/publish.rs @@ -0,0 +1,760 @@ +//! Repository publishing is a resumable sequence, never an implicit push. +use super::transport::{pages, segment, validate_host, Api, Client}; +use super::{run_command, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::Mutex; +use strand_core::Repo; + +// Serialize journal transitions; PR reads and ordinary Git operations do not use this lock. +static PUBLISH_WRITE: Mutex<()> = Mutex::new(()); +const JOURNAL: &str = "strand.publish-state"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Destination { + pub id: String, + pub label: String, + pub kind: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishAccount { + pub account: String, + pub account_id: String, + pub destinations: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishRequest { + pub provider: String, + pub host: String, + pub account_id: String, + pub destination: String, + pub name: String, + pub visibility: String, + pub remote: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishState { + pub id: String, + pub request: PublishRequest, + pub account: String, + pub destination: Destination, + pub url: String, + pub clone_url: String, + pub branch: String, + pub head: String, + pub stage: String, + pub error: Option, +} + +fn check_provider(provider: &str, host: &str) -> Result<()> { + validate_host(host)?; + if !matches!(provider, "github" | "gitlab" | "bitbucket") { + return Err("Choose GitHub, GitLab, or Bitbucket Cloud".into()); + } + if provider == "bitbucket" && host != "bitbucket.org" { + return Err("Bitbucket Server is outside the Cloud adapter scope".into()); + } + Ok(()) +} + +pub fn accounts(path: &str, provider: &str, host: &str) -> Result { + check_provider(provider, host)?; + Repo::discover(path).map_err(|e| e.to_string())?; + account( + &Client { + cwd: path, + provider, + host, + }, + provider, + ) +} + +fn account(api: &impl Api, provider: &str) -> Result { + let viewer = api.json("GET", "user", None)?; + let account_id = viewer + .get(if provider == "bitbucket" { + "uuid" + } else { + "id" + }) + .filter(|v| !v.is_null()) + .ok_or("Provider returned no account identity")? + .to_string(); + let account = string( + &viewer, + if provider == "github" { + "/login" + } else if provider == "gitlab" { + "/username" + } else { + "/display_name" + }, + )?; + let destinations = match provider { + "github" => { + let mut rows = vec![Destination { + id: account.clone(), + label: account.clone(), + kind: "account".into(), + }]; + rows.extend( + pages(api, "user/orgs", false)? + .iter() + .map(|o| { + let login = string(o, "/login")?; + Ok(Destination { + id: login.clone(), + label: login, + kind: "organization".into(), + }) + }) + .collect::>>()?, + ); + rows + } + "gitlab" => pages(api, "namespaces", false)? + .iter() + .map(|n| { + Ok(Destination { + id: n["id"].as_u64().ok_or("Invalid namespace ID")?.to_string(), + label: string(n, "/full_path")?, + kind: string(n, "/kind")?, + }) + }) + .collect::>>()?, + _ => pages(api, "user/workspaces", true)? + .iter() + .map(|n| { + Ok(Destination { + id: string(n, "/workspace/slug")?, + label: string(n, "/workspace/slug")?, + kind: "workspace".into(), + }) + }) + .collect::>>()?, + }; + Ok(PublishAccount { + account, + account_id, + destinations, + }) +} + +pub fn state(path: &str) -> Result> { + Repo::discover(path).map_err(|e| e.to_string())?; + let Ok(bytes) = run_command(path, "git", &["config", "--local", "--get", JOURNAL], &[]) else { + return Ok(None); + }; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| "Publish recovery record is invalid".into()) +} +fn save(path: &str, state: &PublishState) -> Result<()> { + let value = serde_json::to_string(state).map_err(|e| e.to_string())?; + run_command(path, "git", &["config", "--local", JOURNAL, &value], &[])?; + Ok(()) +} + +pub fn forget(path: &str) -> Result<()> { + let _lock = PUBLISH_WRITE + .lock() + .map_err(|_| "Publish state lock failed")?; + if state(path)?.is_some() { + run_command( + path, + "git", + &["config", "--local", "--unset-all", JOURNAL], + &[], + )?; + } + Ok(()) +} + +fn validate_request(request: &PublishRequest) -> Result<()> { + check_provider(&request.provider, &request.host)?; + if !matches!(request.visibility.as_str(), "private" | "public") { + return Err("Choose private or public visibility".into()); + } + if request.name.is_empty() + || request.name.len() > 100 + || request.name.starts_with(['-', '.']) + || request.name.ends_with(".git") + || !request + .name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) + { + return Err("Use a repository name of 1–100 letters, digits, hyphens, underscores or dots; do not start with a dot or hyphen, or end with .git".into()); + } + if request.provider == "bitbucket" && request.name != request.name.to_lowercase() { + return Err("Bitbucket repository slugs must be lowercase".into()); + } + if request.remote.is_empty() + || request.remote.len() > 100 + || request.remote.starts_with(['-', '.']) + || !request + .remote + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)) + { + return Err("Use a remote name with letters, digits, hyphens or underscores".into()); + } + Ok(()) +} + +pub fn preview(path: &str, request: PublishRequest) -> Result { + let _lock = PUBLISH_WRITE + .lock() + .map_err(|_| "Publish state lock failed")?; + validate_request(&request)?; + if state(path)?.is_some_and(|s| !matches!(s.stage.as_str(), "review" | "pushed")) { + return Err("Resume or dismiss the existing publish recovery record first".into()); + } + let repo = Repo::discover(path).map_err(|e| e.to_string())?; + if repo + .refs() + .map_err(|e| e.to_string())? + .remotes + .iter() + .any(|r| r.name == request.remote) + { + return Err("That remote already exists. Choose a new remote name".into()); + } + let account = accounts(path, &request.provider, &request.host)?; + if account.account_id != request.account_id { + return Err("The signed-in account changed; reload destinations".into()); + } + let destination = account + .destinations + .into_iter() + .find(|d| d.id == request.destination) + .ok_or("Destination is no longer available to this account")?; + let meta = repo.meta().map_err(|e| e.to_string())?; + if meta.detached { + return Err("Switch to a local branch before publishing".into()); + } + let head = run_command(path, "git", &["rev-parse", "--verify", "HEAD"], &[]) + .ok() + .and_then(|v| String::from_utf8(v).ok()) + .unwrap_or_default() + .trim() + .to_string(); + let url = format!( + "https://{}/{}/{}", + request.host, + destination + .label + .split('/') + .map(segment) + .collect::>() + .join("/"), + segment(&request.name) + ); + let state = PublishState { + id: uuid::Uuid::new_v4().to_string(), + request, + account: account.account, + destination, + clone_url: format!("{url}.git"), + url, + branch: meta.branch, + head, + stage: "review".into(), + error: None, + }; + save(path, &state)?; + Ok(state) +} + +fn existing_endpoint(s: &PublishState) -> String { + match s.request.provider.as_str() { + "github" => format!( + "repos/{}/{}", + segment(&s.destination.label), + segment(&s.request.name) + ), + "gitlab" => format!( + "projects/{}", + segment(&format!("{}/{}", s.destination.label, s.request.name)) + ), + _ => format!( + "repositories/{}/{}", + segment(&s.destination.label), + segment(&s.request.name) + ), + } +} +fn create_payload(s: &PublishState) -> (String, Value) { + let r = &s.request; + match r.provider.as_str() { + "github" => ( + if s.destination.kind == "account" { + "user/repos".into() + } else { + format!("orgs/{}/repos", segment(&s.destination.label)) + }, + json!({"name":r.name,"private":r.visibility == "private","auto_init":false}), + ), + "gitlab" => ( + "projects".into(), + json!({"name":r.name,"path":r.name,"namespace_id":s.destination.id.parse::().unwrap_or(0),"visibility":r.visibility,"initialize_with_readme":false}), + ), + _ => ( + existing_endpoint(s), + json!({"scm":"git","is_private":r.visibility == "private","name":r.name}), + ), + } +} + +fn validate_created(s: &PublishState, v: &Value) -> Result<()> { + let full = string( + v, + match s.request.provider.as_str() { + "github" => "/full_name", + "gitlab" => "/path_with_namespace", + _ => "/full_name", + }, + )?; + if !full.eq_ignore_ascii_case(&format!("{}/{}", s.destination.label, s.request.name)) { + return Err("Provider returned a different repository destination. Inspect it on the provider website".into()); + } + let private = match s.request.provider.as_str() { + "gitlab" => v["visibility"] == "private", + "bitbucket" => v["is_private"] == true, + _ => v["private"] == true, + }; + if private != (s.request.visibility == "private") { + return Err("Repository visibility differs from the reviewed choice. Inspect it on the provider website".into()); + } + Ok(()) +} + +/// `check` is read-only recovery after an uncertain create. The next explicit +/// `attach` action is the user's decision to use the inspected destination. +pub fn advance(path: &str, id: &str, action: &str) -> Result { + let _lock = PUBLISH_WRITE + .lock() + .map_err(|_| "Publish state lock failed")?; + let s = state(path)?.ok_or("No publish recovery record")?; + let request = s.request.clone(); + advance_using( + path, + id, + action, + s, + &Client { + cwd: path, + provider: &request.provider, + host: &request.host, + }, + ) +} + +fn advance_using( + path: &str, + id: &str, + action: &str, + mut s: PublishState, + api: &impl Api, +) -> Result { + if s.id != id { + return Err("The publish review changed. Reopen the dialog".into()); + } + validate_request(&s.request)?; + let destination_url = format!( + "https://{}/{}/{}", + s.request.host, + s.destination + .label + .split('/') + .map(segment) + .collect::>() + .join("/"), + segment(&s.request.name) + ); + if s.url != destination_url || s.clone_url != format!("{destination_url}.git") { + return Err( + "The saved publish destination changed. Dismiss recovery and review it again".into(), + ); + } + s.error = None; + let result = match action { + "create" if s.stage == "review" => { + let account = account(api, &s.request.provider)?; + if account.account_id != s.request.account_id { + return Err("The signed-in account changed; review the destination again".into()); + } + // Persist before the POST: even a timeout/process exit must not + // invite a blind duplicate creation on the next launch. + s.stage = "uncertain".into(); + save(path, &s)?; + let (endpoint, payload) = create_payload(&s); + api.json("POST", &endpoint, Some(&payload)) + .and_then(|v| validate_created(&s, &v)) + .map(|_| { + s.stage = "created".into(); + }) + } + "check" if s.stage == "uncertain" => api + .json("GET", &existing_endpoint(&s), None) + .and_then(|v| validate_created(&s, &v)) + .map(|_| { + s.stage = "created".into(); + }), + "attach" if s.stage == "created" => attach(path, &s).map(|_| { + s.stage = "remote_ready".into(); + }), + "push" if s.stage == "remote_ready" => push(path, &s).map(|_| { + s.stage = "pushed".into(); + }), + _ => return Err("That publish step is not available; refresh the recovery state".into()), + }; + if let Err(error) = result { + s.error = Some(error); + } + save(path, &s)?; + Ok(s) +} + +fn attach(path: &str, s: &PublishState) -> Result<()> { + let repo = Repo::discover(path).map_err(|e| e.to_string())?; + let refs = repo.refs().map_err(|e| e.to_string())?; + if let Some(remote) = refs.remotes.iter().find(|r| r.name == s.request.remote) { + if repo + .configured_remote_url(&remote.name) + .map_err(|e| e.to_string())? + .as_deref() + != Some(&s.clone_url) + || remote.push_url.is_some() + { + return Err("The remote now exists with another destination. Resolve it in Manage remotes before retrying".into()); + } + } else { + repo.add_remote(&s.request.remote, &s.clone_url, None) + .map_err(|e| e.to_string())?; + } + if s.request.host != "github.com" && s.request.provider == "github" + || s.request.provider == "gitlab" + { + super::set_hosting_provider(path, &s.request.remote, &s.request.provider)?; + } + Ok(()) +} + +fn push(path: &str, s: &PublishState) -> Result<()> { + if s.head.is_empty() { + return Err("This repository had no commit at review time. Create a commit and use the ordinary Push action".into()); + } + super::validate_commit(&s.head)?; + let repo = Repo::discover(path).map_err(|e| e.to_string())?; + let remote = repo + .refs() + .map_err(|e| e.to_string())? + .remotes + .into_iter() + .find(|r| r.name == s.request.remote) + .ok_or("Remote no longer exists")?; + if repo + .configured_remote_url(&remote.name) + .map_err(|e| e.to_string())? + .as_deref() + != Some(&s.clone_url) + || remote.push_url.is_some() + { + return Err("Remote destination changed; initial push stopped".into()); + } + let effective = run_command( + path, + "git", + &["remote", "get-url", "--push", "--all", &remote.name], + &[], + )?; + if String::from_utf8_lossy(&effective).trim() != s.clone_url { + return Err("Git URL rewriting changes the reviewed push destination; inspect remote configuration before pushing".into()); + } + let meta = repo.meta().map_err(|e| e.to_string())?; + if meta.detached || meta.branch != s.branch { + return Err("The checked-out branch changed; use the ordinary Push action to review a new destination".into()); + } + run_command( + path, + "git", + &["check-ref-format", &format!("refs/heads/{}", s.branch)], + &[], + )?; + // Pin the refspec to the reviewed object: a concurrent local commit cannot + // silently expand the first publication. Never force an existing branch. + run_command( + path, + "git", + &[ + "push", + "--no-follow-tags", + "--recurse-submodules=no", + "--", + &s.request.remote, + &format!("{}:refs/heads/{}", s.head, s.branch), + ], + &[("GIT_TERMINAL_PROMPT", "0")], + )?; + // Do not overwrite an existing upstream configuration. + let upstream = run_command( + path, + "git", + &["config", "--get", &format!("branch.{}.remote", s.branch)], + &[], + ); + if upstream.is_err() { + run_command(path, "git", &["branch", &format!("--set-upstream-to={}/{}", s.request.remote, s.branch), &s.branch], &[]) + .map_err(|e| format!("Push succeeded, but upstream setup failed. Retry is safe (same reviewed commit): {e}"))?; + } + Ok(()) +} + +fn string(v: &Value, pointer: &str) -> Result { + v.pointer(pointer) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("Provider returned no {pointer}")) +} + +#[cfg(test)] +mod tests { + use super::super::transport::fixtures::FixtureApi; + use super::*; + fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_str().unwrap(); + run_command(path, "git", &["init", "-q", "-b", "main"], &[]).unwrap(); + run_command( + path, + "git", + &[ + "config", + "core.hooksPath", + dir.path().join("empty-hooks").to_str().unwrap(), + ], + &[], + ) + .unwrap(); + run_command( + path, + "git", + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "initial", + ], + &[], + ) + .unwrap(); + dir + } + fn plan() -> PublishState { + PublishState { + id: "fixture-plan".into(), + request: PublishRequest { + provider: "github".into(), + host: "github.com".into(), + account_id: "1".into(), + destination: "me".into(), + name: "app".into(), + visibility: "private".into(), + remote: "publish".into(), + }, + account: "me".into(), + destination: Destination { + id: "me".into(), + label: "me".into(), + kind: "account".into(), + }, + url: "https://github.com/me/app".into(), + clone_url: "https://github.com/me/app.git".into(), + branch: "main".into(), + head: "a".repeat(40), + stage: "review".into(), + error: None, + } + } + #[test] + fn validates_names_visibility_cloud_scope_and_provider_creation_payloads() { + let mut s = plan(); + assert_eq!( + create_payload(&s), + ( + "user/repos".into(), + json!({"name":"app","private":true,"auto_init":false}) + ) + ); + s.destination.kind = "organization".into(); + assert_eq!(create_payload(&s).0, "orgs/me/repos"); + s.request.provider = "gitlab".into(); + s.destination.id = "123".into(); + assert_eq!(create_payload(&s).1["namespace_id"], 123); + assert_eq!(create_payload(&s).1["initialize_with_readme"], false); + s.request.provider = "bitbucket".into(); + s.request.host = "bitbucket.org".into(); + assert_eq!(create_payload(&s).0, "repositories/me/app"); + assert_eq!(create_payload(&s).1["is_private"], true); + for name in ["../escape", "-option", "has space", "repo.git", "UpperCase"] { + s.request.name = name.into(); + assert!(validate_request(&s.request).is_err()); + } + s.request.name = "app".into(); + s.request.host = "server.example".into(); + assert!(validate_request(&s.request).is_err()); + s = plan(); + assert!(validate_created(&s, &json!({"full_name":"other/app","private":true})).is_err()); + assert!(validate_created(&s, &json!({"full_name":"me/app","private":false})).is_err()); + } + #[test] + fn failed_create_is_journaled_and_recovery_never_reposts() { + let dir = fixture(); + let path = dir.path().to_str().unwrap(); + let api = FixtureApi::new(vec![ + ("GET", "user", Ok(json!({"id":1,"login":"me"}))), + ("GET", "user/orgs?per_page=100", Ok(json!([]))), + ("POST", "user/repos", Err("HTTP 403 or timeout".into())), + ]); + let s = advance_using(path, "fixture-plan", "create", plan(), &api).unwrap(); + assert_eq!(s.stage, "uncertain"); + assert!(s.error.unwrap().contains("403")); + assert_eq!(state(path).unwrap().unwrap().stage, "uncertain"); + assert!(Repo::discover(path) + .unwrap() + .refs() + .unwrap() + .remotes + .is_empty()); + api.done(); + let api = FixtureApi::new(vec![( + "GET", + "repos/me/app", + Ok(json!({"full_name":"me/app","private":true})), + )]); + let s = advance_using( + path, + "fixture-plan", + "check", + state(path).unwrap().unwrap(), + &api, + ) + .unwrap(); + assert_eq!(s.stage, "created"); + assert!(api.writes.borrow().is_empty()); + api.done(); + let api = FixtureApi::new(vec![]); + assert!(advance_using(path, "fixture-plan", "create", s, &api).is_err()); + api.done(); + } + #[test] + fn changed_account_stops_creation_and_remote_failure_is_resumable() { + let dir = fixture(); + let path = dir.path().to_str().unwrap(); + let api = FixtureApi::new(vec![ + ("GET", "user", Ok(json!({"id":2,"login":"other"}))), + ("GET", "user/orgs?per_page=100", Ok(json!([]))), + ]); + assert!(advance_using(path, "fixture-plan", "create", plan(), &api) + .unwrap_err() + .contains("account changed")); + assert!(api.writes.borrow().is_empty()); + let repo = Repo::discover(path).unwrap(); + repo.add_remote("publish", "https://example.test/other.git", None) + .unwrap(); + let mut s = plan(); + s.stage = "created".into(); + let api = FixtureApi::new(vec![]); + let s = advance_using(path, "fixture-plan", "attach", s, &api).unwrap(); + assert_eq!(s.stage, "created"); + assert!(s.error.as_ref().unwrap().contains("another destination")); + repo.remove_remote("publish").unwrap(); + let s = advance_using(path, "fixture-plan", "attach", s, &api).unwrap(); + assert_eq!(s.stage, "remote_ready"); + assert_eq!( + repo.configured_remote_url("publish").unwrap().unwrap(), + s.clone_url + ); + assert!(repo.refs().unwrap().remote_branches.is_empty()); + api.done(); + } + #[test] + fn explicit_push_sends_only_reviewed_object_and_preserves_existing_upstream() { + let dir = fixture(); + let path = dir.path().to_str().unwrap(); + let bare = tempfile::tempdir().unwrap(); + run_command( + bare.path().to_str().unwrap(), + "git", + &["init", "--bare", "-q"], + &[], + ) + .unwrap(); + let mut s = plan(); + s.clone_url = bare.path().to_str().unwrap().into(); + s.head = String::from_utf8(run_command(path, "git", &["rev-parse", "HEAD"], &[]).unwrap()) + .unwrap() + .trim() + .into(); + attach(path, &s).unwrap(); + run_command( + path, + "git", + &["config", "branch.main.remote", "upstream"], + &[], + ) + .unwrap(); + run_command( + path, + "git", + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "new local commit", + ], + &[], + ) + .unwrap(); + push(path, &s).unwrap(); + let pushed = run_command( + bare.path().to_str().unwrap(), + "git", + &["rev-parse", "refs/heads/main"], + &[], + ) + .unwrap(); + assert_eq!(String::from_utf8_lossy(&pushed).trim(), s.head); + assert_eq!( + String::from_utf8_lossy( + &run_command(path, "git", &["config", "branch.main.remote"], &[]).unwrap() + ) + .trim(), + "upstream" + ); + Repo::discover(path) + .unwrap() + .set_remote_urls( + "publish", + &s.clone_url, + Some("https://elsewhere.test/app.git"), + ) + .unwrap(); + assert!(push(path, &s).unwrap_err().contains("destination changed")); + } +} diff --git a/docs/hosted-provider-contracts.md b/docs/hosted-provider-contracts.md index e3c60d04..05518d04 100644 --- a/docs/hosted-provider-contracts.md +++ b/docs/hosted-provider-contracts.md @@ -70,3 +70,25 @@ per-remote settings persistence in a scratch repository. Hosted responses and writes were injected fixtures; no authenticated live-provider mutation was performed. The app used a separate identity/profile and temporary embedded debug configuration; the normal binary was rebuilt after verification. + +## Repository publishing + +`pull_requests/publish.rs` uses GitHub +[repository creation](https://docs.github.com/en/rest/repos/repos), GitLab +[project creation](https://docs.gitlab.com/api/projects/) and +[namespaces](https://docs.gitlab.com/api/namespaces/), and Bitbucket's repository +and workspace APIs linked above. Azure creation is outside this flow. + +The journal in repository-local `strand.publish-state` contains the reviewed +account/destination/visibility, branch and commit, plus recovery stage. It has no +credentials. Creation persists `uncertain` before POST; recovery only performs +GET. Attaching a remote and pushing require separate explicit actions. The +initial push pins the reviewed object, rejects changed or rewritten URLs, +disables implicit tag/submodule pushes and preserves an existing upstream. + +Fixtures cover uncertain creation without duplicate POST, changed accounts, +visibility mismatch and remote conflicts. A real local bare-repository test +proves that only the reviewed object is pushed after a newer local commit and +that an existing upstream survives. The desktop pass exercised destination +review, remote-setup failure, close/resume/retry, an initially unchecked push +checkbox, and completion; it also caught and fixed initial keyboard focus. diff --git a/docs/learnings.md b/docs/learnings.md index 830b32bf..cf6448ae 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2505,3 +2505,11 @@ head. Cloud comment/review races must report that a write may have happened, and partial batches must retain drafts with reconciliation guidance. Never reuse another provider's credentials; Cloud API tokens are scoped to api.bitbucket.org through the system Git credential helper. + +**Repository creation must be resumable before any network write (2026-09-06).** +Persist the concrete destination and reviewed commit locally before creation, +record uncertain state before POST, and recover by GET instead of blindly +reposting. Creation, remote attachment and initial push are separate user +actions. Initial push sends only the reviewed SHA to the reviewed branch, +rejects URL rewrites/alternate push URLs, disables implicit tag/submodule +pushes and preserves an existing upstream. Recovery records contain no tokens. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 973cc417..cbfa4fe4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -114,6 +114,7 @@ const waitForPaint = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); const CloneDialog = lazy(() => import('./views/CloneDialog').then((m) => ({ default: m.CloneDialog }))); +const PublishRepoDialog = lazy(() => import('./views/PublishRepoDialog').then((m) => ({ default: m.PublishRepoDialog }))); const InitRepoDialog = lazy(() => import('./views/InitRepoDialog').then((m) => ({ default: m.InitRepoDialog }))); const SettingsDialog = lazy(() => import('./views/SettingsDialog').then((m) => ({ default: m.SettingsDialog }))); const BranchCleanupDialog = lazy(() => import('./views/BranchCleanupDialog').then((m) => ({ default: m.BranchCleanupDialog }))); @@ -335,6 +336,12 @@ export function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState('appearance'); const [cloneOpen, setCloneOpen] = useState(false); + const [publishRepoOpen, setPublishRepoOpen] = useState(false); + useEffect(() => { + const open = () => setPublishRepoOpen(true); + window.addEventListener('strand:publish-repository', open); + return () => window.removeEventListener('strand:publish-repository', open); + }, []); const [initRepoOpen, setInitRepoOpen] = useState(false); // null = closed; otherwise the flavour the dialog opens in (snapshot vs stash). const [stashDialog, setStashDialog] = useState<{ snapshot: boolean; keepIndex: boolean } | null>(null); @@ -1874,6 +1881,7 @@ export function App() { })(), ] : []), + { id: 'publish-repository', label: 'Publish repository…', group: 'Actions', keywords: 'create hosted github gitlab bitbucket repository account organization visibility initial push', run: () => setPublishRepoOpen(true) }, { id: 'remote-add', label: 'Add remote…', group: 'Actions', keywords: 'remote origin upstream url add', run: () => setRemoteDialog({ kind: 'add' }) }, { id: 'repository-maintenance', label: 'Repository maintenance…', group: 'Actions', keywords: 'git gc fsck integrity optimize activity log command output', run: () => { setPaletteOpen(false); @@ -2344,6 +2352,7 @@ export function App() { setCloneOpen(false)} onStartClone={runClone} /> )} + {publishRepoOpen && activePath && setPublishRepoOpen(false)} />} {initRepoOpen && ( setInitRepoOpen(false)} onInit={runInitRepo} /> )} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index a1b1c73f..6a744332 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1121,6 +1121,7 @@ export function Sidebar({ onOpenWorkbench, onOpenWorkSurface, onOpenRepo, onOpen count={refs.remotes.length} action={{ icon: 'plus', title: 'Add remote…', onClick: () => onManageRemote({ kind: 'add' }) }} /> + {sections.remotes && window.dispatchEvent(new CustomEvent('strand:publish-repository'))} />} {sections.remotes && renderTreeChildren(remoteTree, 0, collapsed, toggleCollapsed, renderRemoteLeaf, 'remotes', { folderIcon: 'remote', diff --git a/ui/src/demo/dispatch.ts b/ui/src/demo/dispatch.ts index 9c51c7b0..a9d55782 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -84,6 +84,11 @@ export const handlers: Record = { azdo_profile_test: () => unavailable('Azure DevOps profiles'), repo_hosting_providers: () => [], repo_set_hosting_provider: () => unavailable('Hosted repository setup'), + hosted_publish_accounts: () => unavailable('Hosted repository setup'), + hosted_publish_state: () => null, + hosted_publish_preview: () => unavailable('Hosted repository setup'), + hosted_publish_advance: () => unavailable('Hosted repository setup'), + hosted_publish_forget: () => unavailable('Hosted repository setup'), hosting_connection_status: () => ({ github: { installed: true, connected: true, account: 'dana', detail: 'gh 2.62.0 · signed in as dana (demo)' }, azure_dev_ops: { installed: false, connected: false, account: null, detail: 'az CLI not installed' }, diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 4dcd3c19..e6203d69 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -1,4 +1,4 @@ -import type { RemoteHostingProvider } from './types'; +import type { RemoteHostingProvider, PublishAccount, PublishRequest, PublishState } from './types'; import { Channel, invoke } from '@tauri-apps/api/core'; import type { @@ -129,6 +129,11 @@ export const tauri = { azdoHelperStatus: () => invoke('azdo_helper_status'), repoHostingProviders: (path: string) => invoke('repo_hosting_providers', { path }), repoSetHostingProvider: (path: string, remote: string, provider: string) => invoke('repo_set_hosting_provider', { path, remote, provider }), + hostedPublishAccounts: (path: string, provider: string, host: string) => invoke('hosted_publish_accounts', { path, provider, host }), + hostedPublishState: (path: string) => invoke('hosted_publish_state', { path }), + hostedPublishPreview: (path: string, request: PublishRequest) => invoke('hosted_publish_preview', { path, request }), + hostedPublishAdvance: (path: string, id: string, action: string) => invoke('hosted_publish_advance', { path, id, action }), + hostedPublishForget: (path: string) => invoke('hosted_publish_forget', { path }), hostingConnectionStatus: () => invoke('hosting_connection_status'), azdoHelperEnable: () => invoke('azdo_helper_enable'), azdoHelperDisable: () => invoke('azdo_helper_disable'), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 8d4999a9..a8912f12 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -930,3 +930,11 @@ export type AiGenerationOutcome = }; export interface RemoteHostingProvider { remote: string; url: string; provider: string } +export interface PublishDestination { id: string; label: string; kind: string } +export interface PublishAccount { account: string; account_id: string; destinations: PublishDestination[] } +export interface PublishRequest { provider: 'github' | 'gitlab' | 'bitbucket'; host: string; account_id: string; destination: string; name: string; visibility: 'private' | 'public'; remote: string } +export interface PublishState { + id: string; request: PublishRequest; account: string; destination: PublishDestination; + url: string; clone_url: string; branch: string; head: string; + stage: 'review' | 'uncertain' | 'created' | 'remote_ready' | 'pushed'; error: string | null; +} diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 03aa8eaa..acd00521 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -9542,3 +9542,7 @@ select.clone-input { .plugin-heroi-select-thinking, .plugin-heroi-select-permission { display: none; } } +.publish-review { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 14px; margin: 0; } +.publish-review dt { color: var(--text-muted); } +.publish-review dd { margin: 0; overflow-wrap: anywhere; } +.publish-review a { color: var(--accent); } diff --git a/ui/src/views/PublishRepoDialog.tsx b/ui/src/views/PublishRepoDialog.tsx new file mode 100644 index 00000000..a2f4ea77 --- /dev/null +++ b/ui/src/views/PublishRepoDialog.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Dialog } from '../components/Dialog'; +import { errMessage, tauri } from '../lib/tauri'; +import type { PublishAccount, PublishRequest, PublishState } from '../lib/types'; +import { useRepo } from '../stores/repo'; + +const DEFAULT_HOST = { github: 'github.com', gitlab: 'gitlab.com', bitbucket: 'bitbucket.org' }; + +export function PublishRepoDialog({ path, onClose }: { path: string; onClose: () => void }) { + const [provider, setProvider] = useState('github'); + const [host, setHost] = useState('github.com'); + const [account, setAccount] = useState(null); + const [destination, setDestination] = useState(''); + const [name, setName] = useState(''); + const [visibility, setVisibility] = useState<'private' | 'public'>('private'); + const [remote, setRemote] = useState('origin'); + const [state, setState] = useState(null); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(null); + const [confirmPush, setConfirmPush] = useState(false); + const [confirmForget, setConfirmForget] = useState(false); + const alive = useRef(true); + const providerRef = useRef(null); + const closeRef = useRef(null); + const focused = useRef(false); + + useEffect(() => { + if (!busy && !focused.current) { + focused.current = true; + (providerRef.current ?? closeRef.current)?.focus(); + } + }, [busy]); + + useEffect(() => { + alive.current = true; + void tauri.hostedPublishState(path).then((value) => { if (alive.current) setState(value); }) + .catch((e) => { if (alive.current) setError(errMessage(e)); }) + .finally(() => { if (alive.current) setBusy(false); }); + return () => { alive.current = false; }; + }, [path]); + + async function run(action: () => Promise) { + if (busy) return; + setBusy(true); setError(null); + try { await action(); } + catch (e) { if (alive.current) setError(errMessage(e)); } + finally { if (alive.current) setBusy(false); } + } + + async function loadAccount() { + const next = await tauri.hostedPublishAccounts(path, provider, host.trim()); + if (alive.current) { setAccount(next); setDestination(next.destinations[0]?.id ?? ''); } + } + async function preview() { + if (!account) return; + const next = await tauri.hostedPublishPreview(path, { provider, host: host.trim(), account_id: account.account_id, destination, name: name.trim(), visibility, remote: remote.trim() }); + if (alive.current) setState(next); + } + async function advance(action: string) { + if (!state) return; + const next = await tauri.hostedPublishAdvance(path, state.id, action); + if (alive.current) { setState(next); setError(next.error); setConfirmPush(false); } + if ((action === 'attach' || action === 'push') && useRepo.getState().activePath === path) await useRepo.getState().refreshRefs(); + } + async function forget() { + await tauri.hostedPublishForget(path); + if (alive.current) { setState(null); setConfirmForget(false); setAccount(null); setError(null); } + } + + const nextAction = state?.stage === 'review' ? 'create' : state?.stage === 'uncertain' ? 'check' : state?.stage === 'created' ? 'attach' : state?.stage === 'remote_ready' ? 'push' : null; + const nextLabel = { create: 'Create repository', check: 'Check destination', attach: 'Add remote', push: 'Push reviewed commit' }; + + return + + {!state && } + {nextAction && } + }> +
+

Create an empty hosted repository for {path}, add its remote, then choose whether to push.

+ {!state ? <> + + +

{provider === 'github' ? 'Uses the active gh account for this host. Switch accounts with gh auth switch --hostname HOST. Enterprise API routing follows gh configuration.' : provider === 'gitlab' ? 'Uses glab authentication for this host. Sign in or switch the active account with glab.' : 'Uses the API credential for api.bitbucket.org in your Git credential helper: Atlassian email and a scoped API token.'}

+ + {account && <> + + + {account.destinations.length === 0 &&

No destinations are available to this account.

} + } + + + + : <> +
+
Destination
{state.url}
+
Account
{state.account}
Visibility
{state.request.visibility}
+
Remote
{state.request.remote} → {state.clone_url}
+
Initial push
{state.head || 'No commit'} → refs/heads/{state.branch}
+
+

{state.stage === 'review' ? 'Review this destination before creating the empty repository. Creation does not push any files.' : state.stage === 'uncertain' ? 'Creation was attempted. Check the destination before deciding whether to attach it; it may already exist. No remote or push has been performed by this flow.' : state.stage === 'created' ? 'The destination exists. Add its remote to continue, or close and resume later.' : state.stage === 'remote_ready' ? 'Remote configured. Initial push is optional and sends only the reviewed commit and its history.' : 'The reviewed commit was pushed successfully.'}

+ {state.stage === 'remote_ready' && } + {state.stage === 'remote_ready' && !state.head &&

Create your first commit, then use Strand’s ordinary Push action.

} + {state.stage === 'review' ? : <> + + {confirmForget && } + } + } + {(error || state?.error) &&
{error || state?.error}
} +
+
; +} diff --git a/website/docs/repositories-and-workspaces.md b/website/docs/repositories-and-workspaces.md index 709106de..fa4c69ec 100644 --- a/website/docs/repositories-and-workspaces.md +++ b/website/docs/repositories-and-workspaces.md @@ -66,6 +66,34 @@ Tabs are deduplicated by canonical path — opening the same repo twice focuses All of these are rebindable in Settings → Keyboard; "Next repository" and "Previous repository" also exist as palette actions. Both tab cycling and the quick switcher are **workspace-aware**: with a workspace active, they move within its members. +## Publish a hosted repository + +With a local repository open, run **Publish repository…** from the command +palette, or **Publish repository** beneath Remotes in the Git sidebar. + +1. Choose GitHub, GitLab or Bitbucket Cloud, enter the host, then **Load account + and destinations**. GitHub Enterprise and custom GitLab hosts are supported. + The active CLI/helper account is displayed; switch accounts in the provider + CLI before loading again if needed. +2. Select the account, organization, namespace or workspace, a repository name, + private/public visibility and a new remote name. Existing remotes are not + overwritten. **Review destination** shows the exact hosted URL, account, + visibility, remote URL, branch and commit to be published. +3. **Create repository** creates an empty destination. **Add remote** then + configures it locally. These steps do not push files. +4. Optionally check **Push the reviewed commit and its history to this + destination**, then **Push reviewed commit**. The checkbox starts unchecked. + The push sends the reviewed commit even if newer local commits now exist, + preserves an existing upstream and never force-pushes. With no commit at + review time, create one and use the ordinary Push action later. + +Close and reopen the dialog to resume. A failed or interrupted creation becomes +an uncertain result: **Check destination** inspects the exact repository without +issuing another create request. Remote setup and push failures retain their +stage for retry. A changed remote URL, Git URL rewrite or changed branch stops +the initial push. Dismissing a recovery record keeps the hosted repository and +local remote. Azure repository creation is not part of this flow. + ## Persistence Strand restores your session across launches: window size, position, and From 43a3fb7416d5954ac2aa7fb91602180535d76146 Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Sun, 6 Sep 2026 18:20:46 +0200 Subject: [PATCH 3/3] Fix provider adapter Clippy borrows --- crates/strand-tauri/src/pull_requests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/strand-tauri/src/pull_requests.rs b/crates/strand-tauri/src/pull_requests.rs index 90a5b88b..7afaf214 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -1059,7 +1059,7 @@ fn create_github( description: &str, is_draft: bool, ) -> Result { - let slug = cwd.slug(&owner, &repo); + let slug = cwd.slug(owner, repo); let source_branch = branch_name(source_branch.to_string()); let target_branch = branch_name(target_branch.to_string()); let mut args = vec![ @@ -1312,7 +1312,7 @@ fn submit_review_github( } fn github_current_head(cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64) -> Result { - let slug = cwd.slug(&owner, &repo); + let slug = cwd.slug(owner, repo); let id = id.to_string(); let output = github_command( cwd, "gh", &["pr", "view", &id, "--repo", &slug, "--json", "headRefOid"], @@ -1352,7 +1352,7 @@ fn prepare_checkout_github( id: u64, expected_head: &str, ) -> Result { - let slug = cwd.slug(&owner, &repo); + let slug = cwd.slug(owner, repo); let number = id.to_string(); let output = github_command( cwd, @@ -1531,7 +1531,7 @@ fn merge_github( strategy: PullRequestMergeStrategy, expected_head: &str, ) -> Result<()> { - let slug = cwd.slug(&owner, &repo); + let slug = cwd.slug(owner, repo); let id = id.to_string(); github_command( cwd, @@ -1552,7 +1552,7 @@ fn merge_github( } fn mark_ready_github(cwd: &GitHubContext<'_>, owner: &str, repo: &str, id: u64) -> Result<()> { - let slug = cwd.slug(&owner, &repo); + let slug = cwd.slug(owner, repo); let id = id.to_string(); github_command( cwd, @@ -1570,7 +1570,7 @@ fn set_lifecycle_github( id: u64, action: PullRequestLifecycleAction, ) -> Result<()> { - let slug = cwd.slug(&owner, &repo); + let slug = cwd.slug(owner, repo); let id = id.to_string(); let verb = github_lifecycle_verb(action); github_command(