diff --git a/Cargo.lock b/Cargo.lock index c6cface..430b11f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5946,6 +5946,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha1_smol", "sha2", "sqlx", "strand-azdo-protocol", diff --git a/README.md b/README.md index 62cd4b2..eec8f1a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,18 @@ 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. +- **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. + - **Personal user actions** — define repository, ref, and working-tree file commands in Settings → Integrations. Menu and palette entries open an exact executable/argument/working-directory preview, with bounded output and cancellation. diff --git a/ROADMAP.md b/ROADMAP.md index 76b0fb7..9fb763f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2200,6 +2200,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 — @@ -2833,6 +2835,20 @@ as did the frontend typecheck and five focused frontend tests. --- +**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. + +**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. + **Patch interchange shipped (2026-09-06):** Added affected-path previews, explicit index/worktree targets, validated import, original-author mailboxes with Continue/Skip/Abort, and bundle verification/import/export with diff --git a/TASKS.md b/TASKS.md index 3cee753..738f360 100644 --- a/TASKS.md +++ b/TASKS.md @@ -135,9 +135,11 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- execution (`bisect.rs`, `BisectDialog`, Repository menu/palette and banner; three native fixtures, two frontend tests and native WebView2 keyboard, external-resume, dirty-reset and original-target checks). -- ☐ **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. (`UserActionsEditor`, `UserActionDialog`, @@ -1945,11 +1947,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 0213b2d..38707d0 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 136280d..1bfd159 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -2477,3 +2477,32 @@ 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 +} +#[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 a8356a8..390b7f5 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -178,6 +178,13 @@ fn main() { commands::repo_refs, commands::azdo_helper_status, 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 9f08edc..7afaf21 100644 --- a/crates/strand-tauri/src/pull_requests.rs +++ b/crates/strand-tauri/src/pull_requests.rs @@ -1,9 +1,13 @@ //! 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 publish; +pub(crate) mod transport; +use transport::{github_command, github_command_input, GitHubContext}; use std::{ collections::HashMap, @@ -138,6 +142,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 +273,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 +390,9 @@ pub enum PullRequestDiffSide { #[derive(Debug, Clone, PartialEq, Eq)] enum HostRepo { + Hosted(hosted::HostedRepo), GitHub { + host: String, owner: String, repo: String, }, @@ -392,7 +412,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 +431,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 +459,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 +538,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 +557,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 +576,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 +596,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 +633,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 +661,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 +692,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 +733,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 +750,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 +768,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 +794,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 +823,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 +838,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 +858,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 +877,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 +916,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 +944,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 +995,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 +1038,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 +1050,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 +1059,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 +1079,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 +1122,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 +1132,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 +1150,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 +1179,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 +1220,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 +1237,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 +1253,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 +1268,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 +1284,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 +1296,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 +1304,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 +1325,7 @@ fn github_current_head(cwd: &str, owner: &str, repo: &str, id: u64) -> Result, owner: &str, repo: &str, id: u64, @@ -1287,7 +1334,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 +1345,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 +1378,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 +1396,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 +1426,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 +1435,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 +1448,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 +1524,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 +1551,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 +1564,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 +3516,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 +4102,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 +4422,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 +4691,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 +4743,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 +4846,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 +4876,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 +4909,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 0000000..2261268 --- /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/publish.rs b/crates/strand-tauri/src/pull_requests/publish.rs new file mode 100644 index 0000000..acd15c1 --- /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/crates/strand-tauri/src/pull_requests/transport.rs b/crates/strand-tauri/src/pull_requests/transport.rs new file mode 100644 index 0000000..3322e5b --- /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 0000000..05518d0 --- /dev/null +++ b/docs/hosted-provider-contracts.md @@ -0,0 +1,94 @@ +# 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. + +## 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 64ee69d..35ecc09 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -2581,6 +2581,25 @@ Pierre reads `navigator.userAgent` during module evaluation; Node 22's built-in 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. + +**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. + ### Advanced refs preserve reviewed identities (2026-09-06) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 86fe54e..18a88f3 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -121,6 +121,8 @@ 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 CloneScopeDialog = lazy(() => import('./views/CloneScopeDialog').then((m) => ({ default: m.CloneScopeDialog }))); const SparseCheckoutDialog = lazy(() => import('./views/SparseCheckoutDialog').then((m) => ({ default: m.SparseCheckoutDialog }))); const InitRepoDialog = lazy(() => import('./views/InitRepoDialog').then((m) => ({ default: m.InitRepoDialog }))); @@ -352,6 +354,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 [cloneScopePath, setCloneScopePath] = useState(null); const [sparsePath, setSparsePath] = useState(null); useEffect(() => { @@ -1943,6 +1951,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: 'git-interchange', label: 'Patches, mailboxes & bundles…', group: 'Actions', keywords: 'import export apply index working tree am continue skip abort author bundle verify prerequisites', run: () => { setPaletteOpen(false); setInterchangePath(meta.path); } }, { id: 'git-notes', label: 'Git notes…', group: 'Actions', keywords: 'advanced refs objects notes replacements tag annotation', run: () => { setPaletteOpen(false); setAdvancedRefs({ path: meta.path, mode: 'notes' }); } }, @@ -2031,6 +2040,7 @@ export function App() { { id: 'keybindings', label: 'Settings: Keyboard shortcuts', group: 'Actions', keywords: 'keyboard shortcuts keybindings rebind configure customize', run: () => openSettingsAt('keyboard') }, { id: 'settings-git', label: 'Settings: Repository identity and signing', group: 'Actions', keywords: 'author committer name email local override config gpg ssh key sign tags', run: () => openSettingsAt('git') }, { 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)) }, { @@ -2435,6 +2445,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 4f1183e..3fecc36 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1123,6 +1123,7 @@ export function Sidebar({ onManageSubmodules, onManageLfs, onOpenWorkbench, onOp 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 1f521b5..06e74ea 100644 --- a/ui/src/demo/dispatch.ts +++ b/ui/src/demo/dispatch.ts @@ -93,6 +93,13 @@ 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'), + 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/hostingCapabilities.test.ts b/ui/src/lib/hostingCapabilities.test.ts new file mode 100644 index 0000000..2b0250a --- /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 11cd68e..7a51dd7 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 05b0be0..55988ca 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -1,3 +1,5 @@ +import type { RemoteHostingProvider, PublishAccount, PublishRequest, PublishState } from './types'; + import type { FlowAction, FlowConfig, FlowKind, FlowOutcome, FlowPlan, FlowState, FlowTool } from './gitflow'; import { Channel, invoke } from '@tauri-apps/api/core'; import type { AdvancedRefs, GitNote, ReplaceReview, TagEditReview, TagEditKind, PublishedTag } from './advancedRefs'; @@ -178,6 +180,13 @@ 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 }), + 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 2596c91..f851456 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -256,7 +256,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'; @@ -339,6 +339,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; @@ -972,6 +973,16 @@ export type AiGenerationOutcome = provider: AiProvider; }; +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; +} + export interface ScopedValue { value: string; scope: string; diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index be3a409..d690f17 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -9560,6 +9560,11 @@ 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); } + /* Repository tools mount only while their dialog is open. */ .git-tool-body { overflow-y: auto; min-height: 0; max-height: 70vh; } .git-tool-body > .btn { align-self: flex-start; } diff --git a/ui/src/views/PublishRepoDialog.tsx b/ui/src/views/PublishRepoDialog.tsx new file mode 100644 index 0000000..a2f4ea7 --- /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/ui/src/views/PullRequestCreateDialog.tsx b/ui/src/views/PullRequestCreateDialog.tsx index 9809ec6..67a863d 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 19ab63e..970cfd1 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 4f8b044..27ee373 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 26b2d8f..82f4388 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/repositories-and-workspaces.md b/website/docs/repositories-and-workspaces.md index 25e1fbc..03fc1eb 100644 --- a/website/docs/repositories-and-workspaces.md +++ b/website/docs/repositories-and-workspaces.md @@ -119,6 +119,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 diff --git a/website/docs/settings.md b/website/docs/settings.md index ab3eb66..04911f8 100644 --- a/website/docs/settings.md +++ b/website/docs/settings.md @@ -69,6 +69,28 @@ hook diagnostics. ## 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