From 8c0b719de4b8a7651609932ff973a58f9297d73c Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 6 Feb 2026 09:57:29 -0700 Subject: [PATCH] feat: add create branch from GitHub issue Add the ability to create a new branch from a GitHub issue in the NewBranchModal. Users can now select 'From Issue' to browse open issues and create a branch with a name derived from the issue number and title. Backend changes: - Add Issue type with number, title, author, updated_at, and labels - Add list_issues and search_issues functions using gh CLI - Register list_issues and search_issues as Tauri commands Frontend changes: - Add Issue type to types.ts - Add listIssues and searchIssues service functions - Update NewBranchModal with issue selection step - Add generateBranchNameFromIssue helper (format: {number}-{slug}) - Display issue labels in the selection list --- src-tauri/src/git/github.rs | 82 ++++++++++++++ src-tauri/src/git/mod.rs | 6 +- src-tauri/src/lib.rs | 31 ++++++ src/lib/NewBranchModal.svelte | 201 ++++++++++++++++++++++++++++++++-- src/lib/services/git.ts | 21 ++++ src/lib/types.ts | 9 ++ 6 files changed, 336 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/git/github.rs b/src-tauri/src/git/github.rs index 3ec19b3..be75728 100644 --- a/src-tauri/src/git/github.rs +++ b/src-tauri/src/git/github.rs @@ -39,6 +39,16 @@ pub struct PullRequest { pub updated_at: String, } +/// A GitHub issue (for display in picker) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + pub number: u64, + pub title: String, + pub author: String, + pub updated_at: String, + pub labels: Vec, +} + // ============================================================================= // Cache // ============================================================================= @@ -285,6 +295,78 @@ pub fn search_pull_requests(repo: &Path, query: &str) -> Result Ok(items.into_iter().map(Into::into).collect()) } +// ============================================================================= +// Issues +// ============================================================================= + +/// Response from `gh issue list --json` +#[derive(Debug, Deserialize)] +struct GhIssueListItem { + number: u64, + title: String, + author: GhAuthor, + #[serde(rename = "updatedAt")] + updated_at: String, + labels: Vec, +} + +#[derive(Debug, Deserialize)] +struct GhLabel { + name: String, +} + +impl From for Issue { + fn from(item: GhIssueListItem) -> Self { + Issue { + number: item.number, + title: item.title, + author: item.author.login, + updated_at: item.updated_at, + labels: item.labels.into_iter().map(|l| l.name).collect(), + } + } +} + +/// List open issues for the repo +pub fn list_issues(repo: &Path) -> Result, GitError> { + let output = run_gh( + repo, + &[ + "issue", + "list", + "--state=open", + "--limit=50", + "--json=number,title,author,updatedAt,labels", + ], + )?; + + let items: Vec = + serde_json::from_str(&output).map_err(|e| GitError::CommandFailed(e.to_string()))?; + + Ok(items.into_iter().map(Into::into).collect()) +} + +/// Search for issues on GitHub using a query string. +/// Uses GitHub's search syntax via `gh issue list --search`. +pub fn search_issues(repo: &Path, query: &str) -> Result, GitError> { + let output = run_gh( + repo, + &[ + "issue", + "list", + "--state=open", + "--limit=50", + &format!("--search={query}"), + "--json=number,title,author,updatedAt,labels", + ], + )?; + + let items: Vec = + serde_json::from_str(&output).map_err(|e| GitError::CommandFailed(e.to_string()))?; + + Ok(items.into_iter().map(Into::into).collect()) +} + /// Fetch PR refs and compute merge-base /// /// - Fetches refs/pull/{number}/head diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 0e55a08..b9614be 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -13,9 +13,9 @@ pub use diff::{get_file_diff, get_unified_diff, list_diff_files}; pub use files::{get_file_at_ref, search_files}; pub use github::{ check_github_auth, create_pull_request, fetch_pr, get_pr_for_branch, - invalidate_cache as invalidate_pr_cache, list_pull_requests, push_branch, search_pull_requests, - sync_review_to_github, update_pull_request, CreatePrResult, GitHubAuthStatus, GitHubSyncResult, - PullRequest, PullRequestInfo, + invalidate_cache as invalidate_pr_cache, list_issues, list_pull_requests, push_branch, + search_issues, search_pull_requests, sync_review_to_github, update_pull_request, + CreatePrResult, GitHubAuthStatus, GitHubSyncResult, Issue, PullRequest, PullRequestInfo, }; pub use refs::{ detect_default_branch, get_repo_root, list_branches, list_refs, merge_base, resolve_ref, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89b47d9..f7f0b3a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -477,6 +477,35 @@ async fn search_pull_requests( .map_err(|e| e.to_string())? } +/// List open issues for the repo. +#[tauri::command(rename_all = "camelCase")] +async fn list_issues(repo_path: Option) -> Result, String> { + let path = repo_path + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + // Run on blocking thread pool to avoid blocking the UI + tokio::task::spawn_blocking(move || git::list_issues(&path).map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? +} + +/// Search for issues on GitHub using a query string. +#[tauri::command(rename_all = "camelCase")] +async fn search_issues( + repo_path: Option, + query: String, +) -> Result, String> { + let path = repo_path + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + // Run on blocking thread pool to avoid blocking the UI + tokio::task::spawn_blocking(move || { + git::search_issues(&path, &query).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + /// Fetch PR refs and compute merge-base. /// Returns DiffSpec with concrete SHAs. #[tauri::command(rename_all = "camelCase")] @@ -3573,6 +3602,8 @@ pub fn run() { check_github_auth, list_pull_requests, search_pull_requests, + list_issues, + search_issues, fetch_pr, sync_review_to_github, invalidate_pr_cache, diff --git a/src/lib/NewBranchModal.svelte b/src/lib/NewBranchModal.svelte index e558c53..9ad8ae6 100644 --- a/src/lib/NewBranchModal.svelte +++ b/src/lib/NewBranchModal.svelte @@ -5,6 +5,7 @@ 1. Pick a repository (with search) 2. Enter branch title (auto-generates sanitized branch name) - Option to switch to "From Pull Request" mode + - Option to switch to "From Issue" mode The branch is created with an isolated worktree, defaulting to the repository's default branch (e.g., origin/main) as the base. @@ -17,6 +18,7 @@ Folder, GitBranch, GitPullRequest, + CircleDot, ChevronRight, Search, Loader2, @@ -27,8 +29,8 @@ import type { Branch } from './services/branch'; import * as branchService from './services/branch'; import { listDirectory, getHomeDir, searchDirectories, type DirEntry } from './services/files'; - import { listRefs, listPullRequests } from './services/git'; - import type { PullRequest } from './types'; + import { listRefs, listPullRequests, listIssues } from './services/git'; + import type { PullRequest, Issue } from './types'; /** Info about a branch being created (for showing a placeholder) */ export interface PendingBranch { @@ -53,7 +55,7 @@ $props(); // State - type Step = 'repo' | 'name' | 'pr'; + type Step = 'repo' | 'name' | 'pr' | 'issue'; let step = $state('repo'); let selectedRepo = $state(null); let branchTitle = $state(''); @@ -66,6 +68,14 @@ let prSearchEl: HTMLInputElement | null = $state(null); let creatingFromPR = $state(false); + // Issue selection state + let issues = $state([]); + let loadingIssues = $state(false); + let issueSearchQuery = $state(''); + let issueSelectedIndex = $state(0); + let issueSearchEl: HTMLInputElement | null = $state(null); + let selectedIssue = $state(null); + /** * Sanitize a branch title into a valid git branch name. * - Converts to lowercase @@ -143,6 +153,15 @@ ); }); + // Filtered issues for search + let filteredIssues = $derived.by(() => { + if (!issueSearchQuery) return issues; + const q = issueSearchQuery.toLowerCase(); + return issues.filter( + (issue) => issue.title.toLowerCase().includes(q) || issue.number.toString().includes(q) + ); + }); + // Initialize onMount(async () => { const dir = await getHomeDir(); @@ -163,6 +182,8 @@ branchInputEl.focus(); } else if (step === 'pr' && prSearchEl) { prSearchEl.focus(); + } else if (step === 'issue' && issueSearchEl) { + issueSearchEl.focus(); } }); @@ -265,6 +286,40 @@ } } + async function switchToIssueMode() { + step = 'issue'; + loadingIssues = true; + issueSearchQuery = ''; + issueSelectedIndex = 0; + try { + issues = await listIssues(selectedRepo!); + } catch (e) { + console.error('Failed to load issues:', e); + issues = []; + } finally { + loadingIssues = false; + } + } + + /** + * Generate a branch name from an issue. + * Format: {issue-number}-{sanitized-title} + * Example: "123-fix-login-bug" + */ + function generateBranchNameFromIssue(issue: Issue): string { + const sanitizedTitle = sanitizeBranchName(issue.title); + // Limit title portion to avoid overly long branch names + const truncatedTitle = sanitizedTitle.slice(0, 50).replace(/-+$/, ''); + return `${issue.number}-${truncatedTitle}`; + } + + function handleSelectIssue(issue: Issue) { + selectedIssue = issue; + // Pre-fill the branch title with the generated name + branchTitle = generateBranchNameFromIssue(issue); + step = 'name'; + } + async function handleSelectPR(pr: PullRequest) { if (!selectedRepo || creatingFromPR) return; creatingFromPR = true; @@ -335,8 +390,18 @@ step = 'name'; prSearchQuery = ''; prSelectedIndex = 0; + } else if (step === 'issue') { + // Go back from issue to name step + step = 'name'; + issueSearchQuery = ''; + issueSelectedIndex = 0; } else if (step === 'name') { // Go back from name to repo selection + // Clear issue selection if we came from issue picker + if (selectedIssue) { + selectedIssue = null; + branchTitle = ''; + } step = 'repo'; selectedRepo = null; detectedDefaultBranch = null; @@ -495,7 +560,7 @@ onclick={(e) => e.stopPropagation()} > + {:else if step === 'issue'} + +
+ + + {#if loadingIssues} + + {/if} +
+ +
+ {#if loadingIssues} +
+ + Loading issues... +
+ {:else if filteredIssues.length === 0} +
+ {issueSearchQuery ? 'No matching issues' : 'No open issues'} +
+ {:else} + {#each filteredIssues as issue, index (issue.number)} + + {/each} + {/if} +
{:else}
@@ -691,10 +805,16 @@
- +
+ + +
diff --git a/src/lib/services/git.ts b/src/lib/services/git.ts index b379f2c..4d7b3eb 100644 --- a/src/lib/services/git.ts +++ b/src/lib/services/git.ts @@ -4,6 +4,7 @@ import type { FileDiffSummary, FileDiff, PullRequest, + Issue, GitHubAuthStatus, GitHubSyncResult, } from '../types'; @@ -161,3 +162,23 @@ export async function syncReviewToGitHub( spec, }); } + +/** + * List open issues for the repo. + */ +export async function listIssues(repoPath?: string): Promise { + return invoke('list_issues', { + repoPath: repoPath ?? null, + }); +} + +/** + * Search for issues on GitHub using a query string. + * Uses GitHub's search syntax. + */ +export async function searchIssues(query: string, repoPath?: string): Promise { + return invoke('search_issues', { + repoPath: repoPath ?? null, + query, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index f5b9faf..636922d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -174,6 +174,15 @@ export interface PullRequest { updated_at: string; } +/** A GitHub issue (for display in picker) */ +export interface Issue { + number: number; + title: string; + author: string; + updated_at: string; + labels: string[]; +} + /** GitHub authentication status */ export interface GitHubAuthStatus { authenticated: boolean;