diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba64d783..2a9de4e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,28 @@ on: paths: - "src/**" - "src-tauri/**" + - "crates/treq-napi/**" + - "supabase/**" + - "web/**" + - "test/**" + - "Cargo.toml" + - "Cargo.lock" + - "package.json" + - "package-lock.json" - ".github/workflows/ci.yml" pull_request: branches: [main] paths: - "src/**" - "src-tauri/**" + - "crates/treq-napi/**" + - "supabase/**" + - "web/**" + - "test/**" + - "Cargo.toml" + - "Cargo.lock" + - "package.json" + - "package-lock.json" - ".github/workflows/ci.yml" jobs: @@ -62,11 +78,11 @@ jobs: sudo apt-get install -y git sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: Install git and jj CLI (macOS) + - name: Install git, jj, and gh CLI (macOS) if: matrix.platform == 'macos-latest' run: | brew update - brew install git jj + brew install git jj gh echo "$(brew --prefix)/bin" >> $GITHUB_PATH - name: Install jj CLI (ubuntu) @@ -75,10 +91,23 @@ jobs: cargo binstall --strategies crate-meta-data jj-cli echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install gh CLI (ubuntu) + if: matrix.platform == 'ubuntu-22.04' + run: | + (type -p wget >/dev/null || (sudo apt-get update && sudo apt-get install -y wget)) \ + && sudo mkdir -p -m 755 /etc/apt/keyrings \ + && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && sudo apt-get update \ + && sudo apt-get install -y gh + - name: Verify pinned tool versions run: | git --version jj --version + gh --version - name: Install dependencies run: npm ci diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index 78df8257..16d6858d 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -6,6 +6,10 @@ on: - main paths: - "web/src/**" + - "web/e2e/**" + - "web/package.json" + - "web/package-lock.json" + - "web/playwright.config.ts" - ".github/workflows/web-e2e.yml" defaults: diff --git a/Makefile b/Makefile index dce49d04..378a28e4 100644 --- a/Makefile +++ b/Makefile @@ -11,4 +11,35 @@ bump: perl -0pi -e 's/"version": "[^"]*"/"version": "'"$$NEW"'"/' src-tauri/tauri.conf.json; \ perl -0pi -e 's/^version = "[^"]*"/version = "'"$$NEW"'"/m' src-tauri/Cargo.toml; \ (cd src-tauri && cargo update -p treq 2>/dev/null) || true; \ - echo "Done." \ No newline at end of file + echo "Done." + +stop: + supabase stop + +restart: + $(MAKE) stop + $(MAKE) start + +db.reset: + supabase db reset --local + +db.diff: + @NAME="$(NAME)"; \ + if [ -z "$$NAME" ]; then \ + printf "Migration name: "; \ + read NAME; \ + fi; \ + if [ -z "$$NAME" ]; then echo "No migration name entered, aborting."; exit 1; fi; \ + supabase db diff --local --file "$$NAME" + + +deploy: + @echo 'Deploying DB migrations now' + @supabase db push + @echo 'Deploying functions now' + @for entrypoint in supabase/functions/*/index.ts; do \ + function_dir=$${entrypoint%/index.ts}; \ + supabase functions deploy "$${function_dir##*/}" || exit $$?; \ + done + +.PHONY: bump start db.ßdiff deploy restart db.reset stop \ No newline at end of file diff --git a/crates/treq-napi/src/dispatch.rs b/crates/treq-napi/src/dispatch.rs index d94740ca..856608f1 100644 --- a/crates/treq-napi/src/dispatch.rs +++ b/crates/treq-napi/src/dispatch.rs @@ -2,6 +2,10 @@ use serde_json::Value; use crate::state; +fn gh_bin() -> Result { + treq_lib::binary_paths::detect_binary("gh").ok_or_else(|| "gh CLI not found".to_string()) +} + /// Dispatch a command by name, with camelCase JSON args. /// Mirrors the Tauri invoke() interface. pub fn dispatch(command: &str, args: Value) -> Result { @@ -27,6 +31,228 @@ pub fn dispatch(command: &str, args: Value) -> Result { Ok(Value::String(dir)) } + // ── GitHub helpers ──────────────────────────────────────────────── + "get_git_remote_url" => { + let repo_path = get_str(&args, "repoPath")?; + let info = treq_lib::github::get_git_remote_url_impl(&repo_path)?; + serde_json::to_value(info).map_err(|e| e.to_string()) + } + + "get_pr_info_via_gh" => { + let repo_path = get_str(&args, "repoPath")?; + let branch_name = get_str(&args, "branchName")?; + let gh = treq_lib::binary_paths::detect_binary("gh") + .ok_or_else(|| "gh CLI not found".to_string())?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + let info = treq_lib::github::get_pr_info_via_gh_impl( + &gh, + &repo_path, + &branch_name, + &extended_path, + )?; + serde_json::to_value(info).map_err(|e| e.to_string()) + } + + "gh_list_issues" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let state = get_str(&args, "state")?; + let limit = get_optional_u32(&args, "limit")? + .unwrap_or(treq_lib::github::GH_LIST_PAGE_SIZE); + let page = get_optional_u32(&args, "page")?.unwrap_or(1); + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + let issues = treq_lib::github::gh_list_issues_impl( + &gh, + &repo_full_name, + &state, + limit, + page, + &extended_path, + )?; + serde_json::to_value(issues).map_err(|e| e.to_string()) + } + + "gh_view_issue" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let issue_number = get_positive_u64(&args, "issueNumber")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + let issue = treq_lib::github::gh_view_issue_impl( + &gh, + &repo_full_name, + issue_number, + &extended_path, + )?; + serde_json::to_value(issue).map_err(|e| e.to_string()) + } + + "gh_create_issue" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let title = get_str(&args, "title")?; + let body = get_str(&args, "body")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + let issue_number = treq_lib::github::gh_create_issue_impl( + &gh, + &repo_full_name, + &title, + &body, + &extended_path, + )?; + Ok(Value::Number(serde_json::Number::from(issue_number))) + } + + "gh_create_issue_comment" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let issue_number = get_positive_u64(&args, "issueNumber")?; + let body = get_str(&args, "body")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + treq_lib::github::gh_create_issue_comment_impl( + &gh, + &repo_full_name, + issue_number, + &body, + &extended_path, + )?; + Ok(Value::Null) + } + + "gh_close_issue" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let issue_number = get_positive_u64(&args, "issueNumber")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + treq_lib::github::gh_close_issue_impl( + &gh, + &repo_full_name, + issue_number, + &extended_path, + )?; + Ok(Value::Null) + } + + "gh_reopen_issue" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let issue_number = get_positive_u64(&args, "issueNumber")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + treq_lib::github::gh_reopen_issue_impl( + &gh, + &repo_full_name, + issue_number, + &extended_path, + )?; + Ok(Value::Null) + } + + "gh_list_prs" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let state = get_str(&args, "state")?; + let limit = get_optional_u32(&args, "limit")? + .unwrap_or(treq_lib::github::GH_LIST_PAGE_SIZE); + let page = get_optional_u32(&args, "page")?.unwrap_or(1); + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + let prs = treq_lib::github::gh_list_prs_impl( + &gh, + &repo_full_name, + &state, + limit, + page, + &extended_path, + )?; + serde_json::to_value(prs).map_err(|e| e.to_string()) + } + + "gh_view_pr" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let pr_number = get_positive_u64(&args, "prNumber")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + let pr = + treq_lib::github::gh_view_pr_impl(&gh, &repo_full_name, pr_number, &extended_path)?; + serde_json::to_value(pr).map_err(|e| e.to_string()) + } + + "gh_create_pr_comment" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let pr_number = get_positive_u64(&args, "prNumber")?; + let body = get_str(&args, "body")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + treq_lib::github::gh_create_pr_comment_impl( + &gh, + &repo_full_name, + pr_number, + &body, + &extended_path, + )?; + Ok(Value::Null) + } + + "gh_close_pr" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let pr_number = get_positive_u64(&args, "prNumber")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + treq_lib::github::gh_close_pr_impl(&gh, &repo_full_name, pr_number, &extended_path)?; + Ok(Value::Null) + } + + "gh_reopen_pr" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let pr_number = get_positive_u64(&args, "prNumber")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + treq_lib::github::gh_reopen_pr_impl(&gh, &repo_full_name, pr_number, &extended_path)?; + Ok(Value::Null) + } + + "gh_set_pr_draft" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let pr_number = get_positive_u64(&args, "prNumber")?; + let draft = args + .get("draft") + .and_then(|v| v.as_bool()) + .ok_or("Missing argument: draft")?; + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + treq_lib::github::gh_set_pr_draft_impl( + &gh, + &repo_full_name, + pr_number, + draft, + &extended_path, + )?; + Ok(Value::Null) + } + + "gh_create_pr" => { + let repo_full_name = get_str(&args, "repoFullName")?; + let title = get_str(&args, "title")?; + let body = get_str(&args, "body")?; + let base_branch = get_str(&args, "baseBranch")?; + let head_branch = get_str(&args, "headBranch")?; + let draft = args + .get("draft") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let gh = gh_bin()?; + let extended_path = treq_lib::binary_paths::get_extended_path(); + let pr_number = treq_lib::github::gh_create_pr_impl( + &gh, + &repo_full_name, + &title, + &body, + &base_branch, + &head_branch, + draft, + &extended_path, + )?; + Ok(Value::Number(serde_json::Number::from(pr_number))) + } + // ── Settings ────────────────────────────────────────────────────── "get_setting" => { let key = get_str(&args, "key")?; @@ -746,6 +972,75 @@ fn get_i64(args: &Value, key: &str) -> Result { .ok_or_else(|| format!("Missing or invalid argument: {}", key)) } +fn get_positive_u64(args: &Value, key: &str) -> Result { + let value = args + .get(key) + .and_then(Value::as_u64) + .filter(|value| *value > 0) + .ok_or_else(|| format!("Missing or invalid positive integer argument: {key}"))?; + Ok(value) +} + +fn get_optional_u32(args: &Value, key: &str) -> Result, String> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => { + let n = value + .as_u64() + .filter(|n| *n > 0 && *n <= u32::MAX as u64) + .ok_or_else(|| format!("Missing or invalid positive integer argument: {key}"))?; + Ok(Some(n as u32)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positive_u64_rejects_negative_zero_and_non_integer_values() { + for value in [ + serde_json::json!(-1), + serde_json::json!(0), + serde_json::json!(1.5), + ] { + let args = serde_json::json!({ "number": value }); + assert!(get_positive_u64(&args, "number").is_err()); + } + } + + #[test] + fn positive_u64_accepts_valid_identifier() { + let args = serde_json::json!({ "number": 42 }); + assert_eq!(get_positive_u64(&args, "number").unwrap(), 42); + } + + #[test] + fn github_routes_reject_negative_identifiers_before_running_gh() { + for (command, key) in [ + ("gh_view_issue", "issueNumber"), + ("gh_create_issue_comment", "issueNumber"), + ("gh_close_issue", "issueNumber"), + ("gh_reopen_issue", "issueNumber"), + ("gh_view_pr", "prNumber"), + ("gh_create_pr_comment", "prNumber"), + ("gh_close_pr", "prNumber"), + ("gh_reopen_pr", "prNumber"), + ("gh_set_pr_draft", "prNumber"), + ] { + let mut args = serde_json::json!({ + "repoFullName": "owner/repo", + "body": "comment", + "draft": false + }); + args[key] = serde_json::json!(-1); + let error = dispatch(command, args).unwrap_err(); + assert!(error.contains("positive integer"), "{command}: {error}"); + } + } +} + fn opt_i64(args: &Value, key: &str) -> Option { args.get(key).and_then(|v| v.as_i64()) } diff --git a/package.json b/package.json index 36d97abd..f3e02968 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "version": "0.1.3", "type": "module", "featureFlags": { - "pro": false + "pro": true, + "stripePayments": false, + "emailSignup": false, + "mergeQueue": false }, "scripts": { "start": "npm run tauri dev open", diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs new file mode 100644 index 00000000..d905c229 --- /dev/null +++ b/src-tauri/src/commands/github.rs @@ -0,0 +1,173 @@ +use crate::binary_paths::{detect_binary, get_binary_path, get_extended_path}; +pub use crate::github::{GhIssue, GhListPage, GhPullRequest, GitRemoteInfo, PrInfo}; + +fn gh_bin() -> Result { + get_binary_path("gh") + .or_else(|| detect_binary("gh")) + .ok_or_else(|| "gh CLI not found".to_string()) +} + +/// Run `gh pr view` for the given branch in the given repo directory. +/// Returns None if gh is not installed, not authenticated, or no PR exists. +#[tauri::command] +pub fn get_pr_info_via_gh( + repo_path: String, + branch_name: String, +) -> Result, String> { + let gh = get_binary_path("gh") + .or_else(|| detect_binary("gh")) + .ok_or_else(|| "gh CLI not found".to_string())?; + + crate::github::get_pr_info_via_gh_impl(&gh, &repo_path, &branch_name, &get_extended_path()) +} + +/// Read the GitHub remote URL from .git/config and parse owner/repo. +/// Returns None if no GitHub remote is found. +#[tauri::command] +pub fn get_git_remote_url(repo_path: String) -> Result, String> { + crate::github::get_git_remote_url_impl(&repo_path) +} + +#[tauri::command] +pub fn gh_list_issues( + repo_full_name: String, + state: String, + limit: Option, + page: Option, +) -> Result, String> { + let gh = gh_bin()?; + crate::github::gh_list_issues_impl( + &gh, + &repo_full_name, + &state, + limit.unwrap_or(crate::github::GH_LIST_PAGE_SIZE), + page.unwrap_or(1), + &get_extended_path(), + ) +} + +#[tauri::command] +pub fn gh_view_issue(repo_full_name: String, issue_number: u64) -> Result { + let gh = gh_bin()?; + crate::github::gh_view_issue_impl(&gh, &repo_full_name, issue_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_create_issue(repo_full_name: String, title: String, body: String) -> Result { + let gh = gh_bin()?; + crate::github::gh_create_issue_impl(&gh, &repo_full_name, &title, &body, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_create_issue_comment( + repo_full_name: String, + issue_number: u64, + body: String, +) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_create_issue_comment_impl( + &gh, + &repo_full_name, + issue_number, + &body, + &get_extended_path(), + ) +} + +#[tauri::command] +pub fn gh_close_issue(repo_full_name: String, issue_number: u64) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_close_issue_impl(&gh, &repo_full_name, issue_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_reopen_issue(repo_full_name: String, issue_number: u64) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_reopen_issue_impl(&gh, &repo_full_name, issue_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_list_prs( + repo_full_name: String, + state: String, + limit: Option, + page: Option, +) -> Result, String> { + let gh = gh_bin()?; + crate::github::gh_list_prs_impl( + &gh, + &repo_full_name, + &state, + limit.unwrap_or(crate::github::GH_LIST_PAGE_SIZE), + page.unwrap_or(1), + &get_extended_path(), + ) +} + +#[tauri::command] +pub fn gh_view_pr(repo_full_name: String, pr_number: u64) -> Result { + let gh = gh_bin()?; + crate::github::gh_view_pr_impl(&gh, &repo_full_name, pr_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_create_pr_comment( + repo_full_name: String, + pr_number: u64, + body: String, +) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_create_pr_comment_impl( + &gh, + &repo_full_name, + pr_number, + &body, + &get_extended_path(), + ) +} + +#[tauri::command] +pub fn gh_close_pr(repo_full_name: String, pr_number: u64) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_close_pr_impl(&gh, &repo_full_name, pr_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_reopen_pr(repo_full_name: String, pr_number: u64) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_reopen_pr_impl(&gh, &repo_full_name, pr_number, &get_extended_path()) +} + +#[tauri::command] +pub fn gh_set_pr_draft(repo_full_name: String, pr_number: u64, draft: bool) -> Result<(), String> { + let gh = gh_bin()?; + crate::github::gh_set_pr_draft_impl( + &gh, + &repo_full_name, + pr_number, + draft, + &get_extended_path(), + ) +} + +#[tauri::command] +pub fn gh_create_pr( + repo_full_name: String, + title: String, + body: String, + base_branch: String, + head_branch: String, + draft: Option, +) -> Result { + let gh = gh_bin()?; + crate::github::gh_create_pr_impl( + &gh, + &repo_full_name, + &title, + &body, + &base_branch, + &head_branch, + draft.unwrap_or(false), + &get_extended_path(), + ) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2d8db383..a8feb6f3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod commits; pub mod file_view; pub mod file_watcher; pub mod filesystem; +pub mod github; pub mod pending_review; pub mod pty_commands; pub mod session; @@ -16,6 +17,7 @@ pub use commits::*; pub use file_view::*; pub use file_watcher::*; pub use filesystem::*; +pub use github::*; pub use pending_review::*; pub use pty_commands::*; pub use session::*; diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs new file mode 100644 index 00000000..df664600 --- /dev/null +++ b/src-tauri/src/github.rs @@ -0,0 +1,1004 @@ +use std::path::Path; + +#[derive(serde::Serialize, Clone)] +pub struct GitRemoteInfo { + pub owner: String, + pub repo: String, + pub full_name: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct PrInfo { + pub number: u64, + pub title: String, + pub state: String, + pub url: String, + pub head_ref_name: String, + pub base_ref_name: String, + pub merge_state_status: Option, + #[serde(default)] + pub is_draft: bool, +} + +/// Parse owner/repo from a GitHub remote URL in SSH or HTTPS form. +/// Returns None if the URL is not a recognized GitHub remote. +pub fn parse_github_remote(url: &str) -> Option { + let url = url.trim(); + + // SSH: git@github.com:owner/repo.git + if let Some(rest) = url.strip_prefix("git@github.com:") { + let path = rest.trim_end_matches(".git"); + let (owner, repo) = valid_repo_path(path)?; + return Some(GitRemoteInfo { + owner: owner.to_string(), + repo: repo.to_string(), + full_name: format!("{owner}/{repo}"), + }); + } + + // HTTPS: https://github.com/owner/repo[.git] + for prefix in &["https://github.com/", "http://github.com/"] { + if let Some(rest) = url.strip_prefix(prefix) { + let path = rest.trim_end_matches(".git"); + let (owner, repo) = valid_repo_path(path)?; + return Some(GitRemoteInfo { + owner: owner.to_string(), + repo: repo.to_string(), + full_name: format!("{owner}/{repo}"), + }); + } + } + + None +} + +fn valid_repo_path(path: &str) -> Option<(&str, &str)> { + let mut parts = path.split('/'); + let owner = parts.next()?; + let repo = parts.next()?; + if owner.is_empty() || repo.is_empty() || parts.next().is_some() { + return None; + } + Some((owner, repo)) +} + +/// Read the GitHub remote URL from .git/config and parse owner/repo. +/// Returns None if no GitHub remote is found or the directory is not a git repo. +pub fn get_git_remote_url_impl(repo_path: &str) -> Result, String> { + let dot_git = Path::new(repo_path).join(".git"); + let git_dir = if dot_git.is_dir() { + dot_git + } else if dot_git.is_file() { + let pointer = std::fs::read_to_string(&dot_git).map_err(|e| e.to_string())?; + let path = pointer + .trim() + .strip_prefix("gitdir:") + .map(str::trim) + .ok_or_else(|| "Malformed .git worktree pointer".to_string())?; + let path = Path::new(path); + if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(repo_path).join(path) + } + } else { + return Ok(None); + }; + let common_dir = git_dir.join("commondir"); + let config_dir = if common_dir.is_file() { + let common = std::fs::read_to_string(common_dir).map_err(|e| e.to_string())?; + git_dir.join(common.trim()) + } else if git_dir.join("config").is_file() { + git_dir + } else if git_dir + .parent() + .is_some_and(|parent| parent.file_name().is_some_and(|name| name == "worktrees")) + { + git_dir + .parent() + .and_then(Path::parent) + .unwrap() + .to_path_buf() + } else { + git_dir + }; + let git_config_path = config_dir.join("config"); + if !git_config_path.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(&git_config_path).map_err(|e| e.to_string())?; + + // Parse INI-style .git/config: find [remote "origin"] section and its url + let mut in_origin_remote = false; + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_origin_remote = trimmed == r#"[remote "origin"]"#; + continue; + } + if in_origin_remote { + if let Some(rest) = trimmed.strip_prefix("url") { + let rest = rest.trim_start(); + if let Some(url) = rest.strip_prefix('=') { + if let Some(info) = parse_github_remote(url.trim()) { + return Ok(Some(info)); + } + } + } + } + } + + Ok(None) +} + +// ── GitHub Issues / PRs ───────────────────────────────────────────────────── + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct GhLabel { + pub name: String, + pub color: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct GhAuthor { + pub login: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct GhIssueComment { + pub id: String, + pub body: String, + pub author: GhAuthor, + pub created_at: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct GhIssue { + pub number: u64, + pub title: String, + pub state: String, + pub url: String, + pub body: Option, + pub author: GhAuthor, + pub labels: Vec, + pub created_at: String, + pub updated_at: String, + pub comments: Option>, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct GhPullRequest { + pub number: u64, + pub title: String, + pub state: String, + pub url: String, + pub body: Option, + pub author: GhAuthor, + pub labels: Vec, + pub head_ref_name: String, + pub base_ref_name: String, + pub merge_state_status: Option, + pub created_at: String, + pub updated_at: String, + pub comments: Option>, + #[serde(default)] + pub is_draft: bool, +} + +fn run_gh( + gh_path: &str, + args: &[&str], + extended_path: &str, +) -> Result { + std::process::Command::new(gh_path) + .args(args) + .env("PATH", extended_path) + .output() + .map_err(|e| e.to_string()) +} + +fn check_gh_output(output: std::process::Output) -> Result, String> { + if output.status.success() { + Ok(output.stdout) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("gh exited with error: {stderr}")) + } +} + +fn parse_number_from_url(url: &str) -> Option { + url.trim().rsplit('/').next()?.parse().ok() +} + +pub const GH_LIST_PAGE_SIZE: u32 = 30; + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GhListPage { + pub items: Vec, + pub has_more: bool, +} + +/// Given items fetched with `--limit (limit * page)`, return the requested page slice. +pub fn take_list_page(fetched: Vec, limit: u32, page: u32) -> GhListPage { + let limit = limit.max(1) as usize; + let page = page.max(1) as usize; + let has_more = fetched.len() == limit * page; + let start = (page - 1) * limit; + let items = if start >= fetched.len() { + Vec::new() + } else { + fetched.into_iter().skip(start).take(limit).collect() + }; + GhListPage { items, has_more } +} + +pub fn gh_list_issues_impl( + gh_path: &str, + repo_full_name: &str, + state: &str, + limit: u32, + page: u32, + extended_path: &str, +) -> Result, String> { + let limit = limit.max(1); + let page = page.max(1); + let fetch_limit = (limit * page).to_string(); + let out = run_gh( + gh_path, + &[ + "issue", + "list", + "--repo", + repo_full_name, + "--state", + state, + "--json", + "number,title,state,url,author,labels,createdAt,updatedAt", + "--limit", + &fetch_limit, + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + let fetched: Vec = + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse gh output: {e}"))?; + Ok(take_list_page(fetched, limit, page)) +} + +pub fn gh_view_issue_impl( + gh_path: &str, + repo_full_name: &str, + issue_number: u64, + extended_path: &str, +) -> Result { + let num = issue_number.to_string(); + let out = run_gh( + gh_path, + &[ + "issue", + "view", + &num, + "--repo", + repo_full_name, + "--json", + "number,title,state,url,body,author,labels,createdAt,updatedAt,comments", + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse gh output: {e}")) +} + +pub fn gh_create_issue_impl( + gh_path: &str, + repo_full_name: &str, + title: &str, + body: &str, + extended_path: &str, +) -> Result { + let out = run_gh( + gh_path, + &[ + "issue", + "create", + "--repo", + repo_full_name, + "--title", + title, + "--body", + body, + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + let text = String::from_utf8_lossy(&bytes); + for line in text.lines() { + if let Some(n) = parse_number_from_url(line) { + return Ok(n); + } + } + Err(format!("Could not parse issue number from output: {text}")) +} + +pub fn gh_create_issue_comment_impl( + gh_path: &str, + repo_full_name: &str, + issue_number: u64, + body: &str, + extended_path: &str, +) -> Result<(), String> { + let num = issue_number.to_string(); + let out = run_gh( + gh_path, + &[ + "issue", + "comment", + &num, + "--repo", + repo_full_name, + "--body", + body, + ], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_close_issue_impl( + gh_path: &str, + repo_full_name: &str, + issue_number: u64, + extended_path: &str, +) -> Result<(), String> { + let num = issue_number.to_string(); + let out = run_gh( + gh_path, + &["issue", "close", &num, "--repo", repo_full_name], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_reopen_issue_impl( + gh_path: &str, + repo_full_name: &str, + issue_number: u64, + extended_path: &str, +) -> Result<(), String> { + let num = issue_number.to_string(); + let out = run_gh( + gh_path, + &["issue", "reopen", &num, "--repo", repo_full_name], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_list_prs_impl( + gh_path: &str, + repo_full_name: &str, + state: &str, + limit: u32, + page: u32, + extended_path: &str, +) -> Result, String> { + let limit = limit.max(1); + let page = page.max(1); + let fetch_limit = (limit * page).to_string(); + let out = run_gh( + gh_path, + &[ + "pr", + "list", + "--repo", + repo_full_name, + "--state", + state, + "--json", + "number,title,state,url,author,labels,headRefName,baseRefName,createdAt,updatedAt,isDraft", + "--limit", + &fetch_limit, + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + let fetched: Vec = + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse gh output: {e}"))?; + Ok(take_list_page(fetched, limit, page)) +} + +pub fn gh_view_pr_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + extended_path: &str, +) -> Result { + let num = pr_number.to_string(); + let out = run_gh( + gh_path, + &[ + "pr", + "view", + &num, + "--repo", + repo_full_name, + "--json", + "number,title,state,url,body,author,labels,headRefName,baseRefName,mergeStateStatus,createdAt,updatedAt,comments,isDraft", + ], + extended_path, + )?; + let bytes = check_gh_output(out)?; + serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse gh output: {e}")) +} + +pub fn gh_create_pr_comment_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + body: &str, + extended_path: &str, +) -> Result<(), String> { + let num = pr_number.to_string(); + let out = run_gh( + gh_path, + &[ + "pr", + "comment", + &num, + "--repo", + repo_full_name, + "--body", + body, + ], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_close_pr_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + extended_path: &str, +) -> Result<(), String> { + let num = pr_number.to_string(); + let out = run_gh( + gh_path, + &["pr", "close", &num, "--repo", repo_full_name], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_reopen_pr_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + extended_path: &str, +) -> Result<(), String> { + let num = pr_number.to_string(); + let out = run_gh( + gh_path, + &["pr", "reopen", &num, "--repo", repo_full_name], + extended_path, + )?; + check_gh_output(out).map(|_| ()) +} + +/// Mark a PR ready for review (`draft = false`) or convert it to draft (`draft = true`). +pub fn gh_set_pr_draft_impl( + gh_path: &str, + repo_full_name: &str, + pr_number: u64, + draft: bool, + extended_path: &str, +) -> Result<(), String> { + let num = pr_number.to_string(); + let mut args = vec!["pr", "ready", num.as_str(), "--repo", repo_full_name]; + if draft { + args.push("--undo"); + } + let out = run_gh(gh_path, &args, extended_path)?; + check_gh_output(out).map(|_| ()) +} + +pub fn gh_create_pr_impl( + gh_path: &str, + repo_full_name: &str, + title: &str, + body: &str, + base_branch: &str, + head_branch: &str, + draft: bool, + extended_path: &str, +) -> Result { + let mut args = vec![ + "pr", + "create", + "--repo", + repo_full_name, + "--title", + title, + "--body", + body, + "--base", + base_branch, + "--head", + head_branch, + ]; + if draft { + args.push("--draft"); + } + let out = run_gh(gh_path, &args, extended_path)?; + let bytes = check_gh_output(out)?; + let text = String::from_utf8_lossy(&bytes); + for line in text.lines() { + if let Some(n) = parse_number_from_url(line) { + return Ok(n); + } + } + Err(format!("Could not parse PR number from output: {text}")) +} + +/// Run `gh pr view` for the given branch in the given repo directory. +/// Returns None if gh is not installed, not authenticated, or no PR exists. +/// The `gh_path` argument is the resolved path to the gh binary. +pub fn get_pr_info_via_gh_impl( + gh_path: &str, + repo_path: &str, + branch_name: &str, + extended_path: &str, +) -> Result, String> { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct GhPrView { + number: u64, + title: String, + state: String, + url: String, + head_ref_name: String, + base_ref_name: String, + merge_state_status: Option, + #[serde(default)] + is_draft: bool, + } + + let output = std::process::Command::new(gh_path) + .args([ + "pr", + "view", + branch_name, + "--json", + "number,title,state,url,headRefName,baseRefName,mergeStateStatus,isDraft", + ]) + .current_dir(repo_path) + .env("PATH", extended_path) + .output() + .map_err(|e| e.to_string())?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr + .to_ascii_lowercase() + .contains("no pull requests found") + { + return Ok(None); + } + return Err(format!("gh pr view failed: {}", stderr.trim())); + } + + let raw: GhPrView = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse gh output: {e}"))?; + + Ok(Some(PrInfo { + number: raw.number, + title: raw.title, + state: raw.state, + url: raw.url, + head_ref_name: raw.head_ref_name, + base_ref_name: raw.base_ref_name, + merge_state_status: raw.merge_state_status, + is_draft: raw.is_draft, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::io::Write; + use tempfile::TempDir; + + // ── parse_github_remote ────────────────────────────────────────────────── + + #[test] + fn test_parse_ssh_url() { + let info = parse_github_remote("git@github.com:owner/repo.git").unwrap(); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo, "repo"); + assert_eq!(info.full_name, "owner/repo"); + } + + #[test] + fn test_parse_https_url() { + let info = parse_github_remote("https://github.com/owner/repo.git").unwrap(); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo, "repo"); + assert_eq!(info.full_name, "owner/repo"); + } + + #[test] + fn test_parse_https_url_no_dot_git() { + let info = parse_github_remote("https://github.com/owner/repo").unwrap(); + assert_eq!(info.full_name, "owner/repo"); + } + + #[test] + fn test_parse_non_github_url_returns_none() { + assert!(parse_github_remote("https://gitlab.com/owner/repo.git").is_none()); + } + + #[test] + fn test_parse_invalid_url_returns_none() { + assert!(parse_github_remote("not-a-url").is_none()); + } + + #[test] + fn rejects_malformed_github_remote_paths() { + for url in [ + "https://github.com/owner", + "https://github.com/owner/", + "https://github.com//repo.git", + "https://github.com/owner/repo/extra", + "git@github.com:owner/repo/extra.git", + ] { + assert!(parse_github_remote(url).is_none(), "accepted {url}"); + } + } + + #[test] + fn test_parse_trims_whitespace() { + let info = parse_github_remote(" git@github.com:owner/repo.git ").unwrap(); + assert_eq!(info.full_name, "owner/repo"); + } + + // ── get_git_remote_url_impl ────────────────────────────────────────────── + + fn write_git_config(dir: &TempDir, content: &str) { + let git_dir = dir.path().join(".git"); + fs::create_dir_all(&git_dir).unwrap(); + fs::write(git_dir.join("config"), content).unwrap(); + } + + #[test] + fn test_get_git_remote_url_parses_ssh_origin() { + let dir = TempDir::new().unwrap(); + write_git_config( + &dir, + r#"[core] + repositoryformatversion = 0 +[remote "origin"] + url = git@github.com:ziinc/treq.git + fetch = +refs/heads/*:refs/remotes/origin/* +"#, + ); + let info = get_git_remote_url_impl(dir.path().to_str().unwrap()) + .unwrap() + .unwrap(); + assert_eq!(info.owner, "ziinc"); + assert_eq!(info.repo, "treq"); + assert_eq!(info.full_name, "ziinc/treq"); + } + + #[test] + fn test_get_git_remote_url_parses_https_origin() { + let dir = TempDir::new().unwrap(); + write_git_config( + &dir, + r#"[remote "origin"] + url = https://github.com/ziinc/treq.git +"#, + ); + let info = get_git_remote_url_impl(dir.path().to_str().unwrap()) + .unwrap() + .unwrap(); + assert_eq!(info.full_name, "ziinc/treq"); + } + + #[test] + fn test_get_git_remote_url_non_github_remote_returns_none() { + let dir = TempDir::new().unwrap(); + write_git_config( + &dir, + r#"[remote "origin"] + url = https://gitlab.com/owner/repo.git +"#, + ); + let result = get_git_remote_url_impl(dir.path().to_str().unwrap()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_get_git_remote_url_missing_git_dir_returns_none() { + let dir = TempDir::new().unwrap(); + let result = get_git_remote_url_impl(dir.path().to_str().unwrap()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_get_git_remote_url_ignores_non_origin_remotes() { + let dir = TempDir::new().unwrap(); + write_git_config( + &dir, + r#"[remote "upstream"] + url = git@github.com:owner/repo.git +"#, + ); + let result = get_git_remote_url_impl(dir.path().to_str().unwrap()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn reads_origin_from_git_worktree_common_config() { + let dir = TempDir::new().unwrap(); + let common = dir.path().join("repo/.git"); + let worktree = dir.path().join("repo/.git/worktrees/feature"); + let checkout = dir.path().join("feature"); + fs::create_dir_all(&worktree).unwrap(); + fs::create_dir_all(&checkout).unwrap(); + fs::write( + common.join("config"), + "[remote \"origin\"]\n url = https://github.com/owner/repo.git\n", + ) + .unwrap(); + fs::write( + checkout.join(".git"), + format!("gitdir: {}\n", worktree.display()), + ) + .unwrap(); + + let info = get_git_remote_url_impl(checkout.to_str().unwrap()) + .unwrap() + .unwrap(); + assert_eq!(info.full_name, "owner/repo"); + } + + // ── get_pr_info_via_gh_impl ────────────────────────────────────────────── + + #[cfg(unix)] + fn write_fake_gh(dir: &TempDir, script_body: &str) -> String { + let path = dir.path().join("gh"); + let mut f = fs::File::create(&path).unwrap(); + writeln!(f, "#!/bin/sh").unwrap(); + write!(f, "{script_body}").unwrap(); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path.to_str().unwrap().to_string() + } + + #[test] + #[cfg(unix)] + fn test_get_pr_info_via_gh_returns_parsed_pr() { + let bin_dir = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh( + &bin_dir, + r#"echo '{"number":42,"title":"My PR","state":"OPEN","url":"https://github.com/o/r/pull/42","headRefName":"feat","baseRefName":"main","mergeStateStatus":"CLEAN","isDraft":false}'"#, + ); + + let info = get_pr_info_via_gh_impl( + &gh_path, + repo_dir.path().to_str().unwrap(), + "feat", + "/usr/bin:/bin", + ) + .unwrap() + .unwrap(); + + assert_eq!(info.number, 42); + assert_eq!(info.title, "My PR"); + assert_eq!(info.state, "OPEN"); + assert_eq!(info.head_ref_name, "feat"); + assert_eq!(info.base_ref_name, "main"); + assert_eq!(info.merge_state_status.as_deref(), Some("CLEAN")); + assert!(!info.is_draft); + } + + #[test] + #[cfg(unix)] + fn get_pr_info_parses_draft_flag() { + let bin_dir = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh( + &bin_dir, + r#"echo '{"number":7,"title":"Draft","state":"OPEN","url":"https://github.com/o/r/pull/7","headRefName":"feat","baseRefName":"main","mergeStateStatus":null,"isDraft":true}'"#, + ); + + let info = get_pr_info_via_gh_impl( + &gh_path, + repo_dir.path().to_str().unwrap(), + "feat", + "/usr/bin:/bin", + ) + .unwrap() + .unwrap(); + assert!(info.is_draft); + } + + #[test] + #[cfg(unix)] + fn get_pr_info_invokes_gh_with_exact_arguments_and_working_directory() { + let bin_dir = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + let expected_dir = repo_dir.path().canonicalize().unwrap(); + let expected_dir = expected_dir.display(); + let script = format!( + r#"test "$PWD" = "{expected_dir}" || exit 8 +test "$*" = "pr view feat --json number,title,state,url,headRefName,baseRefName,mergeStateStatus,isDraft" || exit 9 +echo '{{"number":42,"title":"My PR","state":"OPEN","url":"https://github.com/o/r/pull/42","headRefName":"feat","baseRefName":"main","mergeStateStatus":null,"isDraft":false}}'"#, + ); + let gh_path = write_fake_gh(&bin_dir, &script); + + let result = get_pr_info_via_gh_impl( + &gh_path, + repo_dir.path().to_str().unwrap(), + "feat", + "/usr/bin:/bin", + ); + + assert!(result.unwrap().is_some()); + } + + #[test] + #[cfg(unix)] + fn test_get_pr_info_via_gh_operational_error_is_returned() { + let bin_dir = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh(&bin_dir, "echo 'authentication required' >&2\nexit 1"); + + let result = get_pr_info_via_gh_impl( + &gh_path, + repo_dir.path().to_str().unwrap(), + "feat", + "/usr/bin:/bin", + ); + let error = match result { + Err(error) => error, + Ok(_) => panic!("operational gh failure was treated as no PR"), + }; + + assert!(error.contains("authentication required")); + } + + #[test] + #[cfg(unix)] + fn test_get_pr_info_via_gh_no_pr_state_returns_none() { + let bin_dir = TempDir::new().unwrap(); + let repo_dir = TempDir::new().unwrap(); + // gh exits 1 with a "no pull requests found" message on stderr + let gh_path = write_fake_gh(&bin_dir, "echo 'no pull requests found' >&2\nexit 1"); + + let result = get_pr_info_via_gh_impl( + &gh_path, + repo_dir.path().to_str().unwrap(), + "unknown-branch", + "/usr/bin:/bin", + ) + .unwrap(); + + assert!(result.is_none()); + } + + #[test] + #[cfg(unix)] + fn gh_create_pr_passes_draft_flag_when_requested() { + let bin_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh( + &bin_dir, + r#"test "$*" = "pr create --repo owner/repo --title T --body B --base main --head feat --draft" || exit 9 +echo 'https://github.com/owner/repo/pull/7'"#, + ); + + let number = gh_create_pr_impl( + &gh_path, + "owner/repo", + "T", + "B", + "main", + "feat", + true, + "/usr/bin:/bin", + ) + .unwrap(); + assert_eq!(number, 7); + } + + #[test] + #[cfg(unix)] + fn gh_create_pr_omits_draft_flag_when_not_requested() { + let bin_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh( + &bin_dir, + r#"test "$*" = "pr create --repo owner/repo --title T --body B --base main --head feat" || exit 9 +echo 'https://github.com/owner/repo/pull/8'"#, + ); + + let number = gh_create_pr_impl( + &gh_path, + "owner/repo", + "T", + "B", + "main", + "feat", + false, + "/usr/bin:/bin", + ) + .unwrap(); + assert_eq!(number, 8); + } + + #[test] + #[cfg(unix)] + fn gh_set_pr_draft_marks_ready_without_undo() { + let bin_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh( + &bin_dir, + r#"test "$*" = "pr ready 9 --repo owner/repo" || exit 9 +echo ok"#, + ); + gh_set_pr_draft_impl(&gh_path, "owner/repo", 9, false, "/usr/bin:/bin").unwrap(); + } + + #[test] + #[cfg(unix)] + fn gh_set_pr_draft_converts_to_draft_with_undo() { + let bin_dir = TempDir::new().unwrap(); + let gh_path = write_fake_gh( + &bin_dir, + r#"test "$*" = "pr ready 9 --repo owner/repo --undo" || exit 9 +echo ok"#, + ); + gh_set_pr_draft_impl(&gh_path, "owner/repo", 9, true, "/usr/bin:/bin").unwrap(); + } + + #[test] + fn test_get_pr_info_via_gh_bad_binary_returns_err() { + let result = get_pr_info_via_gh_impl("/nonexistent/gh", "/tmp", "feat", "/usr/bin:/bin"); + assert!(result.is_err()); + } + + #[test] + fn take_list_page_returns_first_page_and_has_more_when_full() { + // Page 1 is fetched with --limit (limit * page) == 2 + let page = take_list_page(vec![1, 2], 2, 1); + assert_eq!(page.items, vec![1, 2]); + assert!(page.has_more); + } + + #[test] + fn take_list_page_returns_second_page_slice() { + // Page 2 is fetched with --limit 4 + let page = take_list_page(vec![1, 2, 3, 4], 2, 2); + assert_eq!(page.items, vec![3, 4]); + assert!(page.has_more); + } + + #[test] + fn take_list_page_has_more_false_when_short_of_full_prefix() { + let page = take_list_page(vec![1, 2, 3], 2, 2); + assert_eq!(page.items, vec![3]); + assert!(!page.has_more); + } + + #[test] + fn take_list_page_has_more_false_on_short_first_page() { + let page = take_list_page(vec![1], 2, 1); + assert_eq!(page.items, vec![1]); + assert!(!page.has_more); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b678748..5f82eb0c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ pub mod conflict_markers; pub mod core; pub mod db; pub mod file_indexer; +pub mod github; pub mod jj; pub mod local_db; pub mod pty; @@ -502,6 +503,21 @@ pub fn run() { commands::get_window_repo_path, commands::rebase_home_repo_branch, commands::dry_run_home_repo_rebase, + commands::get_git_remote_url, + commands::get_pr_info_via_gh, + commands::gh_list_issues, + commands::gh_view_issue, + commands::gh_create_issue, + commands::gh_create_issue_comment, + commands::gh_close_issue, + commands::gh_reopen_issue, + commands::gh_list_prs, + commands::gh_view_pr, + commands::gh_create_pr_comment, + commands::gh_close_pr, + commands::gh_reopen_pr, + commands::gh_set_pr_draft, + commands::gh_create_pr, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/CreatePrButtonGroup.tsx b/src/components/CreatePrButtonGroup.tsx new file mode 100644 index 00000000..09945a66 --- /dev/null +++ b/src/components/CreatePrButtonGroup.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { ChevronDown, Github, Loader2 } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useQueryClient } from "@tanstack/react-query"; +import { ghCreatePr } from "../lib/api"; +import { buildGitHubComparePrUrl } from "../lib/github-pr"; +import type { Workspace } from "../lib/api-types"; +import { usePrInfoViaGh, useGitRemoteInfo } from "../hooks/useMergeQueueStatus"; +import { useToast } from "./ui/toast"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +interface CreatePrButtonGroupProps { + repoPath: string; + workspace: Workspace; + baseBranch: string; +} + +export function CreatePrButtonGroup({ + repoPath, + workspace, + baseBranch, +}: CreatePrButtonGroupProps) { + const { addToast } = useToast(); + const queryClient = useQueryClient(); + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + const { data: prInfo, isLoading: prInfoLoading } = usePrInfoViaGh( + repoPath, + workspace.branch_name, + ); + const [creating, setCreating] = useState(false); + + if (!remoteInfo || prInfoLoading || prInfo) { + return null; + } + + const title = workspace.title || workspace.branch_name; + const body = workspace.description ?? ""; + + const createPr = async (draft: boolean) => { + setCreating(true); + try { + const number = await ghCreatePr( + remoteInfo.full_name, + title, + body, + baseBranch, + workspace.branch_name, + draft, + ); + await queryClient.invalidateQueries({ + queryKey: ["pr-info-gh", repoPath, workspace.branch_name], + }); + const prUrl = `https://github.com/${remoteInfo.full_name}/pull/${number}`; + addToast({ + title: draft ? "Draft PR created" : "Pull request created", + description: `#${number}`, + type: "success", + action: { + label: "Open in Web", + onClick: () => openUrl(prUrl), + }, + }); + } catch (err) { + addToast({ + title: "Failed to create PR", + description: (err as Error).message, + type: "error", + }); + } finally { + setCreating(false); + } + }; + + const openManual = () => { + openUrl( + buildGitHubComparePrUrl({ + owner: remoteInfo.owner, + repo: remoteInfo.repo, + baseBranch, + headBranch: workspace.branch_name, + title, + body, + }), + ); + }; + + return ( +
+ + + + + + + { + e.preventDefault(); + void createPr(true); + }} + > + Create draft PR + + { + e.preventDefault(); + openManual(); + }} + > + Create PR manually + + + +
+ ); +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 5576dccc..75be1283 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -22,6 +22,7 @@ import type { ClaudeSessionData } from "./terminal/types"; import { SettingsPage } from "./SettingsPage"; import { MergePreviewPage } from "./MergePreviewPage"; +import { GitHubPanel } from "./GitHubPanel"; import { useToast } from "./ui/toast"; import { useKeyboardShortcut } from "../hooks/useKeyboard"; import { useWorkspaceHierarchy } from "../hooks/useWorkspaceHierarchy"; @@ -62,7 +63,12 @@ import { import { Onboarding } from "./Onboarding"; import type { BranchListItem } from "./TargetBranchSelector"; -type ViewMode = "session" | "show-workspace" | "settings" | "merge-preview"; +type ViewMode = + | "session" + | "show-workspace" + | "settings" + | "merge-preview" + | "github"; type SessionOpenOptions = { initialPrompt?: string; @@ -138,6 +144,23 @@ export const Dashboard: React.FC = ({ setViewMode("settings"); }, []); + const openGitHub = useCallback(() => { + setViewMode("github"); + }, []); + + const [githubInitialPrNumber, setGithubInitialPrNumber] = useState< + number | null + >(null); + + const openGitHubPr = useCallback((prNumber: number) => { + setGithubInitialPrNumber(prNumber); + setViewMode("github"); + }, []); + + const clearGithubInitialPr = useCallback(() => { + setGithubInitialPrNumber(null); + }, []); + const handleOpenMergePreview = useCallback(() => { if (selectedWorkspace) { setMergeWorkspace(selectedWorkspace); @@ -997,12 +1020,15 @@ export const Dashboard: React.FC = ({ handleCreateSessionFromSidebar(workspace.id) } onStartShell={handleStartShellFromSidebar} + onOpenGitHub={openGitHub} currentPage={ viewMode === "settings" ? "settings" - : viewMode === "session" || viewMode === "show-workspace" - ? "session" - : undefined + : viewMode === "github" + ? "github" + : viewMode === "session" || viewMode === "show-workspace" + ? "session" + : undefined } /> @@ -1032,6 +1058,7 @@ export const Dashboard: React.FC = ({ onDeleteWorkspace={handleDelete} onOpenFilePicker={() => setShowFilePicker(true)} onOpenMergePreview={handleOpenMergePreview} + onViewPrInApp={openGitHubPr} onOpenBranchSwitcher={() => setShowBranchSwitcher(true)} onCreateStackedWorkspace={handleCreateStackedWorkspace} onNavigateToWorkspace={handleSelectWorkspace} @@ -1172,6 +1199,15 @@ export const Dashboard: React.FC = ({ /> )} + {/* GitHub Panel */} + {viewMode === "github" && ( + + )} + {/* Merge Preview View */} {viewMode === "merge-preview" && mergeWorkspace && ( { + const { + user, + session, + loading: authLoading, + subscription, + signIn, + } = useAuth(); + const [repositories, setRepositories] = useState([]); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + const isPro = + subscription?.plan === "pro" && subscription.status === "active"; + + useEffect(() => { + if (!user || !session) return; + let active = true; + setLoading(true); + setFailed(false); + supabase + .from("github_repositories") + .select("id, full_name, private, default_branch, installation_id") + .then(({ data, error }) => { + if (!active) return; + setRepositories(error ? [] : ((data ?? []) as GitHubRepository[])); + setFailed(Boolean(error)); + setLoading(false); + }); + return () => { + active = false; + }; + }, [user, session]); + + if (authLoading) { + return ( +
+ +
+ ); + } + + if (!user || !session) { + return ( +
+ +
+

+ Sign in to manage integrations +

+

+ Connect your GitHub repositories through Treq. +

+
+ +
+ ); + } + + const visibleRepositories = isPro + ? repositories + : repositories.filter((repo) => !repo.private); + + return ( +
+
+
+ +
+

GitHub

+

+ {isPro ? "All repositories" : "Public repositories"} +

+
+
+ {loading ? ( +
+ + Loading GitHub repositories… +
+ ) : failed ? ( +

+ Could not load GitHub repositories. Try again later. +

+ ) : visibleRepositories.length === 0 ? ( +

+ No enabled GitHub repositories. +

+ ) : ( +
    + {visibleRepositories.map((repo) => ( +
  • + {repo.full_name} + + {repo.private ? "Private" : "Public"} + +
  • + ))} +
+ )} + +
+
+ ); +}; diff --git a/src/components/GitHubPanel.tsx b/src/components/GitHubPanel.tsx new file mode 100644 index 00000000..5ad4f641 --- /dev/null +++ b/src/components/GitHubPanel.tsx @@ -0,0 +1,504 @@ +import { useEffect, useState } from "react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { + AlertCircle, + CircleDot, + Github, + GitMerge, + GitPullRequest, + Loader2, + Plus, + RefreshCw, + Rocket, +} from "lucide-react"; +import { Button } from "./ui/button"; +import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; +import { useAuth } from "../hooks/useAuth"; +import { useGitRemoteInfo } from "../hooks/useMergeQueueStatus"; +import { GH_LIST_PAGE_SIZE, ghListIssues, ghListPrs } from "../lib/api"; +import type { QueueEntryStatus } from "../lib/api-types"; +import { WEB_URL, supabase } from "../lib/supabase"; +import { CreateIssueForm, IssueDetailPanel } from "./github-panel/IssueDetail"; +import { CreatePrForm, PrDetailPanel } from "./github-panel/PrDetail"; +import { EmptyState, IssueListItem, PrListItem } from "./github-panel/shared"; + +type TabValue = "issues" | "prs" | "merge-queue"; +type StateFilter = "open" | "closed" | "all"; + +interface GitHubPanelProps { + repoPath: string; + /** When set, opens the PRs tab and selects this PR. */ + initialPrNumber?: number | null; + onInitialPrConsumed?: () => void; +} + +interface QueueBranchEntry { + branch_name: string; + status: QueueEntryStatus; + position: number; + target_branch: string; +} + +const FILTERS: { label: string; value: StateFilter }[] = [ + { label: "Open", value: "open" }, + { label: "Closed", value: "closed" }, + { label: "All", value: "all" }, +]; + +function queueStatusLabel(status: QueueEntryStatus): string { + switch (status) { + case "queued": + return "Queued"; + case "testing": + return "Testing"; + case "passed": + return "Passed"; + case "merged": + return "Merged"; + case "failed": + return "Failed"; + case "dequeued": + return "Dequeued"; + default: + return status; + } +} + +function QueueStatusChip({ status }: { status: QueueEntryStatus }) { + const color = + status === "testing" + ? "bg-amber-500/20 text-amber-600 dark:text-amber-400" + : status === "failed" + ? "bg-red-500/20 text-red-600 dark:text-red-400" + : status === "merged" || status === "passed" + ? "bg-green-500/20 text-green-600 dark:text-green-400" + : "bg-muted text-muted-foreground"; + + return ( + + {queueStatusLabel(status)} + + ); +} + +function MergeQueueUpsell() { + return ( +
+
+
+ +
+
+
+ PRO +
+

+ Unlock Merge Queue +

+

+ Queue stacked PRs, run CI in parallel lanes, and merge with + confidence. Upgrade to Pro to manage your repository's merge + queue from Treq. +

+
+ +
+
+ ); +} + +export const GitHubPanel: React.FC = ({ + repoPath, + initialPrNumber = null, + onInitialPrConsumed, +}) => { + const { subscription } = useAuth(); + const isPro = + subscription?.plan === "pro" && subscription.status === "active"; + const { data: remoteInfo, isLoading: remoteLoading } = + useGitRemoteInfo(repoPath); + const [activeTab, setActiveTab] = useState("issues"); + const [issueFilter, setIssueFilter] = useState("open"); + const [prFilter, setPrFilter] = useState("open"); + const [selectedIssue, setSelectedIssue] = useState(null); + const [selectedPr, setSelectedPr] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + + useEffect(() => { + if (initialPrNumber == null) return; + setActiveTab("prs"); + setPrFilter("all"); + setSelectedIssue(null); + setShowCreateForm(false); + setSelectedPr(initialPrNumber); + onInitialPrConsumed?.(); + }, [initialPrNumber, onInitialPrConsumed]); + + const repoFullName = remoteInfo?.full_name ?? ""; + const isListTab = activeTab === "issues" || activeTab === "prs"; + + const { + data: issuesData, + isLoading: issuesLoading, + isFetchingNextPage: issuesFetchingNext, + hasNextPage: issuesHasNextPage, + fetchNextPage: fetchNextIssues, + refetch: refetchIssues, + } = useInfiniteQuery({ + queryKey: ["gh-issues", repoFullName, issueFilter], + queryFn: ({ pageParam }) => + ghListIssues(repoFullName, issueFilter, GH_LIST_PAGE_SIZE, pageParam), + initialPageParam: 1, + getNextPageParam: (lastPage, _pages, lastPageParam) => + lastPage.hasMore ? lastPageParam + 1 : undefined, + enabled: !!repoFullName && activeTab === "issues", + }); + + const { + data: prsData, + isLoading: prsLoading, + isFetchingNextPage: prsFetchingNext, + hasNextPage: prsHasNextPage, + fetchNextPage: fetchNextPrs, + refetch: refetchPrs, + } = useInfiniteQuery({ + queryKey: ["gh-prs", repoFullName, prFilter], + queryFn: ({ pageParam }) => + ghListPrs(repoFullName, prFilter, GH_LIST_PAGE_SIZE, pageParam), + initialPageParam: 1, + getNextPageParam: (lastPage, _pages, lastPageParam) => + lastPage.hasMore ? lastPageParam + 1 : undefined, + enabled: !!repoFullName && activeTab === "prs", + }); + + const issues = issuesData?.pages.flatMap((page) => page.items) ?? []; + const prs = prsData?.pages.flatMap((page) => page.items) ?? []; + + const { + data: queueEntries = [], + isLoading: queueLoading, + refetch: refetchQueue, + } = useQuery({ + queryKey: ["repo-branch-queue-statuses-panel", repoFullName], + queryFn: async () => { + const { data, error } = await supabase.rpc( + "get_repo_branch_queue_statuses", + { p_repo_full_name: repoFullName }, + ); + if (error) throw error; + return ((data ?? []) as QueueBranchEntry[]).slice().sort((a, b) => { + if (a.position !== b.position) return a.position - b.position; + return a.branch_name.localeCompare(b.branch_name); + }); + }, + enabled: !!repoFullName && activeTab === "merge-queue" && isPro, + refetchInterval: 30_000, + }); + + const isListLoading = activeTab === "issues" ? issuesLoading : prsLoading; + const currentFilter = activeTab === "issues" ? issueFilter : prFilter; + const setCurrentFilter = + activeTab === "issues" ? setIssueFilter : setPrFilter; + + const showDetail = + (activeTab === "issues" && selectedIssue !== null) || + (activeTab === "prs" && selectedPr !== null); + + function handleTabChange(v: string) { + setActiveTab(v as TabValue); + setSelectedIssue(null); + setSelectedPr(null); + setShowCreateForm(false); + } + + function handleSelectIssue(n: number) { + setSelectedIssue(n); + setShowCreateForm(false); + } + + function handleSelectPr(n: number) { + setSelectedPr(n); + setShowCreateForm(false); + } + + function handleNewClick() { + setShowCreateForm(true); + setSelectedIssue(null); + setSelectedPr(null); + } + + function handleRefresh() { + if (activeTab === "issues") void refetchIssues(); + else if (activeTab === "prs") void refetchPrs(); + else void refetchQueue(); + } + + return ( +
+ {/* List panel */} +
+
+
+ +
+ {remoteLoading ? ( +

+ GitHub + Loading… +

+ ) : remoteInfo ? ( +

+ GitHub + {remoteInfo.full_name} +

+ ) : ( +

+ No GitHub remote +

+ )} +
+
+
+ +
+ + + Issues + Pull Requests + + Merge Queue + {!isPro && ( + + PRO + + )} + + + +
+ + {isListTab && ( +
+
+ {FILTERS.map((btn) => ( + + ))} +
+
+ + {remoteInfo && ( + + )} +
+ )} + + {showCreateForm && remoteInfo && isListTab && ( +
+ {activeTab === "issues" ? ( + { + setShowCreateForm(false); + setSelectedIssue(n); + }} + onCancel={() => setShowCreateForm(false)} + /> + ) : ( + { + setShowCreateForm(false); + setSelectedPr(n); + }} + onCancel={() => setShowCreateForm(false)} + /> + )} +
+ )} + +
+ {!remoteInfo && !remoteLoading && activeTab !== "merge-queue" && ( +
+ +

+ No GitHub remote detected for this repository. +

+
+ )} + + {remoteInfo && isListTab && isListLoading && ( +
+ +
+ )} + + {remoteInfo && !isListLoading && activeTab === "issues" && ( + <> + {issues.length === 0 ? ( + + ) : ( + <> + {issues.map((issue) => ( + handleSelectIssue(issue.number)} + /> + ))} + {issuesHasNextPage && ( +
+ +
+ )} + + )} + + )} + + {remoteInfo && !isListLoading && activeTab === "prs" && ( + <> + {prs.length === 0 ? ( + + ) : ( + <> + {prs.map((pr) => ( + handleSelectPr(pr.number)} + /> + ))} + {prsHasNextPage && ( +
+ +
+ )} + + )} + + )} + + {activeTab === "merge-queue" && !isPro && } + + {remoteInfo && activeTab === "merge-queue" && isPro && ( + <> + {queueLoading ? ( +
+ +
+ ) : queueEntries.length === 0 ? ( + + ) : ( + queueEntries.map((entry) => ( +
+
+ + + #{entry.position} + +
+

+ {entry.branch_name} +

+

+ → {entry.target_branch} +

+
+ )) + )} + + )} +
+
+ + {/* Detail panel */} + {showDetail && ( +
+ {activeTab === "issues" && selectedIssue !== null && ( + setSelectedIssue(null)} + /> + )} + {activeTab === "prs" && selectedPr !== null && ( + setSelectedPr(null)} + /> + )} +
+ )} +
+ ); +}; diff --git a/src/components/MarkdownContent.tsx b/src/components/MarkdownContent.tsx new file mode 100644 index 00000000..477e3381 --- /dev/null +++ b/src/components/MarkdownContent.tsx @@ -0,0 +1,42 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; +import { cn } from "../lib/utils"; + +const PROSE_CLASS = + "prose dark:prose-invert max-w-none prose-headings:font-semibold prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-a:text-blue-500 prose-a:no-underline hover:prose-a:underline prose-strong:font-semibold prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:before:content-[''] prose-code:after:content-[''] prose-pre:bg-muted prose-pre:border prose-pre:border-border"; + +interface MarkdownContentProps { + content: string; + className?: string; + /** Optional image src resolver (used by README for relative paths). */ + resolveImageSrc?: (src: string | undefined) => string | undefined; +} + +export function MarkdownContent({ + content, + className, + resolveImageSrc, +}: MarkdownContentProps) { + const stripped = content.replace(//g, ""); + + return ( +
+ ( + {alt + ), + } + : undefined + } + > + {stripped} + +
+ ); +} diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index f4b9ea1e..c2bd563e 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -8,11 +8,11 @@ import { useTheme } from "../hooks/useTheme"; import { useTerminalSettings } from "../hooks/useTerminalSettings"; import { useToast } from "./ui/toast"; import { getSetting, setSetting } from "../lib/api"; -import { FolderGit2, GitBranch, Settings, User } from "lucide-react"; +import { FolderGit2, GitBranch, Plug, Settings, User } from "lucide-react"; import { AccountSettings } from "./AccountSettings"; -import { FEATURES } from "../lib/features"; +import { GitHubIntegrationSettings } from "./GitHubIntegrationSettings"; -type TabValue = "application" | "repository" | "account"; +type TabValue = "application" | "repository" | "account" | "integrations"; interface SettingsPageProps { repoPath: string; @@ -110,12 +110,14 @@ export const SettingsPage: React.FC = ({ Application - {FEATURES.pro && ( - - - Account - - )} + + + Account + + + + Integrations + {/* Right content area */} @@ -278,11 +280,12 @@ export const SettingsPage: React.FC = ({ )} - {FEATURES.pro && ( - - - - )} + + + + + +
diff --git a/src/components/ShowWorkspace.merge-queue.test.tsx b/src/components/ShowWorkspace.merge-queue.test.tsx new file mode 100644 index 00000000..c925239b --- /dev/null +++ b/src/components/ShowWorkspace.merge-queue.test.tsx @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "../../test/test-utils"; +import { ShowWorkspace } from "./ShowWorkspace"; +import type { Workspace } from "../lib/api"; +import * as api from "../lib/api"; + +const { mockFeatures } = vi.hoisted(() => ({ + mockFeatures: { + pro: true, + stripePayments: false, + emailSignup: false, + mergeQueue: false, + }, +})); + +vi.mock("../lib/features", () => ({ + FEATURES: mockFeatures, +})); + +vi.mock("./FileBrowser", () => ({ + FileBrowser: () =>
, +})); + +vi.mock("./LinearCommitHistory", () => ({ + LinearCommitHistory: () =>
, +})); + +vi.mock("./ChangesDiffViewer", () => ({ + ChangesDiffViewer: () =>
, +})); + +vi.mock("./TargetBranchSelector", () => ({ + TargetBranchSelector: () =>
, +})); + +vi.mock("../lib/api", async () => { + const actual = + await vi.importActual("../lib/api"); + return { + ...actual, + getSetting: vi.fn().mockResolvedValue(null), + lsWorkspace: vi.fn().mockResolvedValue([]), + getWorkspaceReadme: vi.fn().mockResolvedValue(null), + jjGetDefaultBranch: vi.fn().mockResolvedValue("main"), + listConflictedFiles: vi.fn().mockResolvedValue([]), + jjGetBranches: vi.fn().mockResolvedValue([ + { name: "main", is_current: false }, + { name: "feature-one", is_current: true }, + ]), + setWorkspaceTargetBranch: vi.fn().mockResolvedValue(undefined), + jjGetChangedFiles: vi.fn().mockResolvedValue([]), + createSession: vi.fn().mockResolvedValue(42), + checkAndRebaseWorkspaces: vi.fn().mockResolvedValue({ + rebased: false, + success: true, + has_conflicts: false, + conflicted_files: [], + message: "No rebase needed", + bookmark_conflicts: [], + }), + listCommits: vi.fn().mockResolvedValue({ + commits: [], + edges: [], + has_more: false, + }), + getWorkspaceStatus: vi.fn(), + }; +}); + +vi.mock("../hooks/useMergeQueueStatus", () => ({ + useMergeQueueStatus: () => ({ data: null }), + useEnqueueWorkspace: () => ({ + enqueue: { mutateAsync: vi.fn(), isPending: false }, + dequeue: { mutateAsync: vi.fn(), isPending: false }, + }), + useGitRemoteInfo: () => ({ data: null }), + usePrInfoViaGh: () => ({ data: null, isLoading: false }), +})); + +describe("ShowWorkspace - Add to Queue feature flag", () => { + const workspace: Workspace = { + id: 1, + repo_path: "/Users/test/repo", + workspace_name: "feature-one", + workspace_path: "/Users/test/repo/.jj/workspaces/feature-one", + branch_name: "feature-one", + title: "feature-one", + created_at: new Date().toISOString(), + not_on_remote: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockFeatures.mergeQueue = false; + vi.mocked(api.getWorkspaceStatus).mockResolvedValue({ + current: workspace, + has_conflicts: false, + has_changes: false, + conflicted_files: [], + remote_sync: { type: "InSync" }, + target: null, + children: [], + dag_nodes: [], + conflicted_workspace_ids: [], + commits_ahead_of_target: [], + }); + }); + + it("hides Add to Queue when mergeQueue feature flag is off", async () => { + render( + , + ); + + expect( + await screen.findByRole("button", { name: /merge/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /add to queue/i }), + ).not.toBeInTheDocument(); + }); + + it("shows Add to Queue when mergeQueue feature flag is on", async () => { + mockFeatures.mergeQueue = true; + + render( + , + ); + + expect( + await screen.findByRole("button", { name: /add to queue/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/ShowWorkspace.tsx b/src/components/ShowWorkspace.tsx index c3286bd6..e1af342f 100644 --- a/src/components/ShowWorkspace.tsx +++ b/src/components/ShowWorkspace.tsx @@ -1,9 +1,6 @@ /* eslint-disable max-lines, max-params */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import rehypeRaw from "rehype-raw"; import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { DirectoryEntry, @@ -70,6 +67,7 @@ import { GitBranch, GitCommitHorizontal, GitCompareArrows, + GitMerge, Layers2, Loader2, MoreVertical, @@ -83,8 +81,16 @@ import { type BranchListItem, } from "./TargetBranchSelector"; import { TaskInput } from "./TaskInput"; +import { MarkdownContent } from "./MarkdownContent"; import { useTerminalSettings } from "../hooks/useTerminalSettings"; +import { + useEnqueueWorkspace, + useMergeQueueStatus, +} from "../hooks/useMergeQueueStatus"; +import { FEATURES } from "../lib/features"; import type { SessionCreationInfo } from "../types/sessions"; +import { CreatePrButtonGroup } from "./CreatePrButtonGroup"; +import { ViewPrButton } from "./ViewPrButton"; interface ShowWorkspaceProps { repositoryPath?: string; @@ -99,6 +105,7 @@ interface ShowWorkspaceProps { onCreateStackedWorkspace?: () => void; /** Called when the user clicks a sibling workspace in the stack panel */ onNavigateToWorkspace?: (workspace: Workspace) => void; + onViewPrInApp?: (prNumber: number) => void; /** Called when user wants to move a commit to a new workspace */ onMoveCommitToNewWorkspace?: ( commit: import("../lib/api").JjLogCommit, @@ -134,6 +141,7 @@ export const ShowWorkspace = memo( onOpenBranchSwitcher, onCreateStackedWorkspace, onNavigateToWorkspace, + onViewPrInApp, onMoveCommitToNewWorkspace, onMoveCommitToExistingWorkspace, onMoveFilesToNewWorkspace, @@ -150,6 +158,15 @@ export const ShowWorkspace = memo( const { addToast } = useToast(); const { fontSize } = useTerminalSettings(); + const { data: queueStatus } = useMergeQueueStatus( + effectiveRepoPath || undefined, + workspace?.branch_name, + ); + const { enqueue, dequeue } = useEnqueueWorkspace( + effectiveRepoPath || undefined, + workspace?.branch_name, + ); + const [changedFiles, setChangedFiles] = useState< Map >(new Map()); @@ -1155,26 +1172,12 @@ export const ShowWorkspace = memo(

README.md

-
- ( - {alt - ), - }} - > - {readmeContent.replace(//g, "")} - -
+ + resolveReadmeImageSrc(src, readmeBaseDir) + } + /> ) : (
@@ -1480,6 +1483,25 @@ export const ShowWorkspace = memo( )} + {/* Create / View PR once the branch is on a GitHub remote */} + {workspace && + !workspace.not_on_remote && + workspace.branch_name !== defaultBranch && + effectiveRepoPath && ( + <> + + + + )} + {/* Sync control - status + icon in one clickable button */} {(!workspace || !workspace.not_on_remote) && syncStatus && @@ -1520,6 +1542,77 @@ export const ShowWorkspace = memo( )} + {/* Merge queue button */} + {FEATURES.mergeQueue && + workspace && + workspace.branch_name !== defaultBranch && + !workspace.not_on_remote && ( + + + + + + + {queueStatus + ? queueStatus.status === "merged" + ? "Merged via queue" + : queueStatus.status === "testing" + ? `CI running in lane ${queueStatus.lane_number ?? "?"}` + : queueStatus.status === "failed" + ? `Failed: ${queueStatus.failure_reason ?? "unknown"}` + : `In merge queue at position ${queueStatus.position}` + : "Add this branch to the merge queue"} + + + + )} {/* Merge button moved here */} {workspace && workspace.branch_name !== defaultBranch && ( diff --git a/src/components/ViewPrButton.tsx b/src/components/ViewPrButton.tsx new file mode 100644 index 00000000..44c50a1a --- /dev/null +++ b/src/components/ViewPrButton.tsx @@ -0,0 +1,90 @@ +import { + ExternalLink, + GitMerge, + GitPullRequest, + GitPullRequestClosed, + GitPullRequestDraft, + type LucideIcon, +} from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { usePrInfoViaGh, useGitRemoteInfo } from "../hooks/useMergeQueueStatus"; +import { Button } from "./ui/button"; +import { cn } from "../lib/utils"; + +interface ViewPrButtonProps { + repoPath: string; + branchName: string; + onViewInApp?: (prNumber: number) => void; +} + +const PR_STATUS_STYLES: Record< + string, + { label: string; className: string; Icon: LucideIcon } +> = { + OPEN: { + label: "View PR", + className: + "border-green-600 text-green-700 hover:bg-green-600/10 hover:text-green-700 dark:text-green-400 dark:border-green-500", + Icon: GitPullRequest, + }, + CLOSED: { + label: "View PR", + className: + "border-red-600 text-red-700 hover:bg-red-600/10 hover:text-red-700 dark:text-red-400 dark:border-red-500", + Icon: GitPullRequestClosed, + }, + MERGED: { + label: "View PR", + className: + "border-purple-600 text-purple-700 hover:bg-purple-600/10 hover:text-purple-700 dark:text-purple-400 dark:border-purple-500", + Icon: GitMerge, + }, +}; + +export function ViewPrButton({ + repoPath, + branchName, + onViewInApp, +}: ViewPrButtonProps) { + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + const { data: prInfo, isLoading } = usePrInfoViaGh(repoPath, branchName); + + if (!remoteInfo || isLoading || !prInfo) { + return null; + } + + const style = PR_STATUS_STYLES[prInfo.state] ?? PR_STATUS_STYLES.OPEN; + const Icon = prInfo.is_draft ? GitPullRequestDraft : style.Icon; + const statusLabel = prInfo.is_draft ? "draft" : prInfo.state.toLowerCase(); + + return ( +
+ + +
+ ); +} diff --git a/src/components/WorkspaceSidebar.tsx b/src/components/WorkspaceSidebar.tsx index f6280d15..9fee8e6e 100644 --- a/src/components/WorkspaceSidebar.tsx +++ b/src/components/WorkspaceSidebar.tsx @@ -1,14 +1,26 @@ import { useQuery } from "@tanstack/react-query"; import { memo, useCallback, useMemo, useState } from "react"; import { DragDropContext, type DropResult, Droppable } from "@hello-pangea/dnd"; -import { GitBranch, Home, Search, Settings, Trash2 } from "lucide-react"; +import { + GitBranch, + Github, + Home, + Search, + Settings, + Trash2, +} from "lucide-react"; import { type Workspace, getWorkspaceStatus, getWorkspaces, listWorkspaceStatuses, } from "../lib/api"; -import type { WorkspaceSidebarStatus } from "../lib/api-types"; +import type { + WorkspaceSidebarStatus, + QueueEntryStatus, +} from "../lib/api-types"; +import { useGitRemoteInfo } from "../hooks/useMergeQueueStatus"; +import { supabase } from "../lib/supabase"; import { buildWorkspaceTree, flattenWorkspaceTree, @@ -51,6 +63,7 @@ interface WorkspaceSidebarProps { navigateToDashboard?: () => void; onOpenCommandPalette?: () => void; onOpenBranchSwitcher?: () => void; + onOpenGitHub?: () => void; currentPage?: string; onAddBefore?: (workspace: Workspace) => void; onAddAfter?: (workspace: Workspace) => void; @@ -73,6 +86,7 @@ export const WorkspaceSidebar: React.FC = memo( openSettings, onOpenCommandPalette, onOpenBranchSwitcher, + onOpenGitHub, currentPage, onAddAfter, onMoveWorkspace, @@ -113,6 +127,26 @@ export const WorkspaceSidebar: React.FC = memo( placeholderData: (previousData) => previousData, }); + const { data: remoteInfo } = useGitRemoteInfo(repoPath); + const { data: branchQueueStatuses } = useQuery({ + queryKey: ["repo-branch-queue-statuses", remoteInfo?.full_name], + queryFn: async () => { + const { data } = await supabase.rpc("get_repo_branch_queue_statuses", { + p_repo_full_name: remoteInfo!.full_name, + }); + const map = new Map(); + for (const row of (data ?? []) as { + branch_name: string; + status: string; + }[]) { + map.set(row.branch_name, row.status as QueueEntryStatus); + } + return map; + }, + enabled: !!remoteInfo, + refetchInterval: 30_000, + }); + const statuses = useMemo(() => { const statusById = new Map( (workspaceStatuses ?? []).map((status) => [status.current.id, status]), @@ -205,6 +239,19 @@ export const WorkspaceSidebar: React.FC = memo( ? repoPath.split("/").filter(Boolean).pop() || "Repository" : "Repository"; + // GitHub/Settings are their own nav destinations — don't keep home/workspace + // selection highlighted alongside them. + const workspaceSelectionActive = + currentPage !== "github" && currentPage !== "settings"; + const isHomeSelected = + workspaceSelectionActive && selectedWorkspaceId === null; + const activeSelectedWorkspaceId = workspaceSelectionActive + ? selectedWorkspaceId + : undefined; + const activeSelectedWorkspaceIds = workspaceSelectionActive + ? selectedWorkspaceIds + : undefined; + return (
@@ -257,9 +304,7 @@ export const WorkspaceSidebar: React.FC = memo(
{ if ( @@ -280,14 +325,14 @@ export const WorkspaceSidebar: React.FC = memo( > = memo( + {onOpenGitHub && ( + + )} + {workspaces.length > 0 && (
)} @@ -351,8 +427,8 @@ export const WorkspaceSidebar: React.FC = memo( node={node} index={index} repoPath={repoPath} - selectedWorkspaceId={selectedWorkspaceId} - selectedWorkspaceIds={selectedWorkspaceIds} + selectedWorkspaceId={activeSelectedWorkspaceId} + selectedWorkspaceIds={activeSelectedWorkspaceIds} onWorkspaceClick={onWorkspaceClick} onWorkspaceMultiSelect={onWorkspaceMultiSelect} onAddAfter={onAddAfter} @@ -361,6 +437,9 @@ export const WorkspaceSidebar: React.FC = memo( onDeleteWorkspace={onDeleteWorkspace} onRenameWorkspace={setRenameTarget} onDoubleClick={handleDoubleClick} + queueStatus={branchQueueStatuses?.get( + node.status.current.branch_name, + )} /> ))} {droppableProvided.placeholder} diff --git a/src/components/WorkspaceSidebarItem.tsx b/src/components/WorkspaceSidebarItem.tsx index 6ac0bbbd..775cfc95 100644 --- a/src/components/WorkspaceSidebarItem.tsx +++ b/src/components/WorkspaceSidebarItem.tsx @@ -11,7 +11,7 @@ import { Terminal, Trash2, } from "lucide-react"; -import type { Workspace } from "../lib/api"; +import type { Workspace, QueueEntryStatus } from "../lib/api"; import type { FlattenedWorkspaceNode } from "../lib/workspace-tree"; import { cn, getFullWorkspacePath } from "../lib/utils"; import { getWorkspaceTitle as getWorkspaceTitleFromUtils } from "../lib/workspace-utils"; @@ -124,6 +124,30 @@ interface WorkspaceSidebarItemProps { onDeleteWorkspace?: (workspace: Workspace) => void; onRenameWorkspace: (workspace: Workspace) => void; onDoubleClick?: (workspace: Workspace, event: React.MouseEvent) => void; + queueStatus?: QueueEntryStatus; +} + +function queueStatusDot(status: QueueEntryStatus): { + color: string; + label: string; +} { + switch (status) { + case "queued": + return { color: "bg-yellow-400", label: "In merge queue" }; + case "testing": + return { + color: "bg-blue-400 animate-pulse", + label: "CI running in merge queue", + }; + case "passed": + return { color: "bg-green-400", label: "Passed CI, awaiting merge" }; + case "merged": + return { color: "bg-green-600", label: "Merged via queue" }; + case "failed": + return { color: "bg-red-500", label: "Failed in merge queue" }; + default: + return { color: "bg-muted-foreground", label: status }; + } } export const WorkspaceSidebarItem: React.FC = ({ @@ -140,6 +164,7 @@ export const WorkspaceSidebarItem: React.FC = ({ onDeleteWorkspace, onRenameWorkspace, onDoubleClick, + queueStatus, }) => { const workspace = node.status.current; const isSelected = @@ -204,6 +229,19 @@ export const WorkspaceSidebarItem: React.FC = ({ aria-label="Conflicted workspace" /> )} + {queueStatus && ( + + + + + + {queueStatusDot(queueStatus).label} + + + )}
void; +}) { + const qc = useQueryClient(); + const [commentBody, setCommentBody] = useState(""); + + const { data: issue, isLoading } = useQuery({ + queryKey: ["gh-issue", repoFullName, issueNumber], + queryFn: () => ghViewIssue(repoFullName, issueNumber), + }); + + const addComment = useMutation({ + mutationFn: () => + ghCreateIssueComment(repoFullName, issueNumber, commentBody), + onSuccess: () => { + setCommentBody(""); + void qc.invalidateQueries({ + queryKey: ["gh-issue", repoFullName, issueNumber], + }); + }, + }); + + const closeIssue = useMutation({ + mutationFn: () => ghCloseIssue(repoFullName, issueNumber), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: ["gh-issue", repoFullName, issueNumber], + }); + void qc.invalidateQueries({ queryKey: ["gh-issues", repoFullName] }); + }, + }); + + const reopenIssue = useMutation({ + mutationFn: () => ghReopenIssue(repoFullName, issueNumber), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: ["gh-issue", repoFullName, issueNumber], + }); + void qc.invalidateQueries({ queryKey: ["gh-issues", repoFullName] }); + }, + }); + + return ( +
+
+ + Issue #{issueNumber} + + +
+ + {isLoading && ( +
+ +
+ )} + + {issue && ( +
+
+
+

+ {issue.title} +

+ +
+
+ + + #{issue.number} opened by {issue.author.login} on{" "} + {formatDate(issue.created_at)} + +
+ {issue.labels.length > 0 && ( +
+ {issue.labels.map((l) => ( + + ))} +
+ )} +
+ + {issue.body && ( +
+ +
+ )} + + {(issue.comments ?? []).length > 0 && ( +
+

+ + Comments ({issue.comments!.length}) +

+ {issue.comments!.map((c) => ( +
+
+ {c.author.login} + · + {formatDate(c.created_at)} +
+ +
+ ))} +
+ )} + +
+

+ Add Comment +

+