feat(create-review): load repository branches and suggest local branch

Add a backend command to enumerate a repository's branches and default
branch for configured integrations, and expose it to the UI. The create
review dialog now fetches branches, shows loading and error states, and
uses searchable SelectMenu controls for repositories and branches. If a
local repository path is available the dialog will try to match remotes
and preselect a local branch that exists on the remote to streamline
review creation.

- Add integration branch listing command and wire it into the dialog
- Replace plain selects with searchable SelectMenu and improved UX
- Attempt to detect and suggest a matching local source branch when possible
This commit is contained in:
2026-09-08 21:41:29 +02:00
parent f7f85d387d
commit ee35169feb
8 changed files with 174 additions and 22 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
use std::time::Duration;
mod creation;
pub use creation::create_integration_review_request;
pub use creation::{create_integration_review_request, list_integration_repository_branches};
const PAGE_SIZE: usize = 100;
const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35);
+63
View File
@@ -103,3 +103,66 @@ mod tests {
assert_eq!(creation_endpoint("azure-devops", "https://dev.azure.com/org", &repository).unwrap().as_str(), "https://dev.azure.com/org/_apis/git/repositories/17/pullrequests?api-version=7.1");
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RepositoryBranches {
branches: Vec<String>,
default_branch: String,
}
#[tauri::command]
pub async fn list_integration_repository_branches(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository) -> Result<RepositoryBranches, String> {
tauri::async_runtime::spawn_blocking(move || {
if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); }
let client = client()?;
let mut metadata_url = creation_endpoint(&provider, &base_url, &repository)?;
metadata_url.path_segments_mut().map_err(|_| "Invalid repository URL")?.pop();
let get = |url: reqwest::Url| -> Result<Response, String> {
let request = client.get(url).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json");
let request = match provider.as_str() {
"github" => request.bearer_auth(&token),
"gitea" => request.header("Authorization", format!("token {token}")),
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token),
"azure-devops" => request.basic_auth(&username, Some(&token)),
_ => return Err("Unsupported integration provider.".into()),
};
let response = request.send().map_err(|e| format!("Could not load branches: {e}"))?;
if !response.status().is_success() { return Err(response_error(response, &provider)); }
Ok(response)
};
let metadata: serde_json::Value = get(metadata_url.clone())?.json().map_err(|e| format!("Invalid repository response: {e}"))?;
let default_branch = value_string(&metadata, &[if provider == "azure-devops" { "defaultBranch" } else { "default_branch" }]).trim_start_matches("refs/heads/").to_string();
let mut branches = BTreeSet::new();
let mut continuation = String::new();
for page in 1..=1000 {
let mut url = metadata_url.clone();
{
let mut path = url.path_segments_mut().map_err(|_| "Invalid repository URL")?;
if provider.starts_with("gitlab") { path.push("repository"); }
path.push(if provider == "azure-devops" { "refs" } else { "branches" });
}
if provider == "azure-devops" {
url.query_pairs_mut().append_pair("filter", "heads/").append_pair("$top", "100");
if !continuation.is_empty() { url.query_pairs_mut().append_pair("continuationToken", &continuation); }
} else {
url.query_pairs_mut().append_pair("page", &page.to_string()).append_pair(if provider == "gitea" { "limit" } else { "per_page" }, "100");
}
let response = get(url)?;
let next = response.headers().get("x-ms-continuationtoken").and_then(|h| h.to_str().ok()).unwrap_or_default().to_string();
let data: serde_json::Value = response.json().map_err(|e| format!("Invalid branch response: {e}"))?;
let items = if provider == "azure-devops" { data.get("value") } else { Some(&data) }.and_then(serde_json::Value::as_array).ok_or("Invalid branch list.")?;
for item in items {
let name = value_string(item, &["name"]);
let name = if provider == "azure-devops" { name.strip_prefix("refs/heads/").unwrap_or(&name) } else { &name };
if !name.is_empty() { branches.insert(name.to_string()); }
}
if (provider == "azure-devops" && next.is_empty()) || (provider != "azure-devops" && items.len() < 100) {
return Ok(RepositoryBranches { branches: branches.into_iter().collect(), default_branch });
}
if provider == "azure-devops" && next == continuation { return Err("The server repeated its branch pagination token.".into()); }
continuation = next;
}
Err("The repository has too many branches to load completely.".into())
}).await.map_err(|e| format!("Could not load branches: {e}"))?
}
+2 -1
View File
@@ -34,7 +34,7 @@ use git::{
unstage_files, untrack_paths, update_remote,
};
use integrations::{
create_integration_review_request, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
list_integration_repository_branches, create_integration_review_request, add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
run_integration_review_action,
};
use std::path::{Path, PathBuf};
@@ -437,6 +437,7 @@ async fn main() {
cred_load,
cred_save,
cred_delete,
list_integration_repository_branches,
create_integration_review_request,
list_integration_repositories,
list_integration_review_requests,