diff --git a/packages/desktop/src-tauri/src/commands/github.rs b/packages/desktop/src-tauri/src/commands/github.rs index d74d7344..a4c8f711 100644 --- a/packages/desktop/src-tauri/src/commands/github.rs +++ b/packages/desktop/src-tauri/src/commands/github.rs @@ -54,6 +54,105 @@ pub struct GitHubPullRequestSummary { mergeable_state: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestHeadRepo { + owner: String, + repo: String, + url: String, + #[serde(skip_serializing_if = "Option::is_none")] + clone_url: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestContextResult { + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + issue_comments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + review_comments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + files: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + diff: Option, + #[serde(skip_serializing_if = "Option::is_none")] + checks: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestsListResult { + connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + prs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + page: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_more: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestContext { + #[serde(flatten)] + summary: GitHubPullRequestSummary, + #[serde(skip_serializing_if = "Option::is_none")] + author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + head_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + head_repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestFile { + filename: String, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + additions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + deletions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + changes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + patch: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPullRequestReviewComment { + id: u64, + url: String, + body: String, + #[serde(skip_serializing_if = "Option::is_none")] + author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + position: Option, + #[serde(skip_serializing_if = "Option::is_none")] + created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct GitHubPullRequestStatus { @@ -142,6 +241,10 @@ pub struct GitHubIssuesListResult { repo: Option, #[serde(skip_serializing_if = "Option::is_none")] issues: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + page: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_more: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -351,6 +454,65 @@ struct IssueCommentResponse { updated_at: Option, } +#[derive(Debug, Deserialize)] +struct PullListItem { + number: u64, + title: String, + html_url: String, + state: String, + #[serde(default)] + draft: bool, + #[serde(default)] + merged_at: Option, + #[serde(default)] + user: Option, + #[serde(default)] + body: Option, + #[serde(default)] + base: Option, + #[serde(default)] + head: Option, + #[serde(default)] + mergeable: Option, + #[serde(default)] + mergeable_state: Option, +} + +#[derive(Debug, Deserialize)] +struct PullFileResponse { + filename: String, + #[serde(default)] + status: Option, + #[serde(default)] + additions: Option, + #[serde(default)] + deletions: Option, + #[serde(default)] + changes: Option, + #[serde(default)] + patch: Option, +} + +#[derive(Debug, Deserialize)] +struct PullReviewCommentResponse { + id: u64, + html_url: String, + #[serde(default)] + body: Option, + #[serde(default)] + user: Option, + #[serde(default)] + path: Option, + #[serde(default)] + line: Option, + #[serde(default)] + position: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, +} + #[derive(Debug, Deserialize)] struct PrListItem { number: u64, @@ -1378,6 +1540,7 @@ pub async fn github_pr_ready( #[tauri::command] pub async fn github_issues_list( directory: String, + page: Option, _state: State<'_, DesktopRuntime>, ) -> Result { let directory = directory.trim().to_string(); @@ -1391,6 +1554,8 @@ pub async fn github_issues_list( connected: false, repo: None, issues: None, + page: None, + has_more: None, }); }; if stored.access_token.trim().is_empty() { @@ -1399,6 +1564,8 @@ pub async fn github_issues_list( connected: false, repo: None, issues: None, + page: None, + has_more: None, }); } @@ -1408,27 +1575,46 @@ pub async fn github_issues_list( connected: true, repo: None, issues: Some(vec![]), + page: Some(page.unwrap_or(1).max(1) as u64), + has_more: Some(false), }); }; + let page = page.unwrap_or(1).max(1); let url = format!( - "{}/{}/{}/issues?state=open&per_page=50", - API_PULLS_URL_PREFIX, repo.owner, repo.repo + "{}/{}/{}/issues?state=open&per_page=50&page={}", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, page ); - let list = github_get_json::>(&url, &stored.access_token).await; - let list = match list { - Ok(v) => v, - Err(err) if err == "unauthorized" => { - let _ = clear_auth_file().await; - return Ok(GitHubIssuesListResult { - connected: false, - repo: None, - issues: None, - }); - } - Err(err) => return Err(err), - }; + let resp = reqwest::Client::new() + .get(url) + .header("Accept", "application/vnd.github+json") + .header("Authorization", format!("Bearer {}", stored.access_token)) + .header("User-Agent", "OpenChamber") + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let _ = clear_auth_file().await; + return Ok(GitHubIssuesListResult { + connected: false, + repo: None, + issues: None, + page: None, + has_more: None, + }); + } + if !resp.status().is_success() { + return Err(format!("GitHub request failed: {}", resp.status())); + } + let link = resp + .headers() + .get("link") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let has_more = link.contains("rel=\"next\""); + let list = resp.json::>().await.map_err(|e| e.to_string())?; let issues = list .into_iter() @@ -1447,6 +1633,8 @@ pub async fn github_issues_list( connected: true, repo: Some(repo), issues: Some(issues), + page: Some(page as u64), + has_more: Some(has_more), }) } @@ -1617,3 +1805,511 @@ pub async fn github_issue_comments( comments: Some(mapped), }) } + +fn read_string_field(value: &Value, key: &str) -> String { + value + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +fn read_bool_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(|v| v.as_bool()) +} + +fn read_number_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(|v| v.as_u64()) +} + +fn map_pr_user(value: &Value) -> Option { + let login = value.get("login").and_then(|v| v.as_str()).unwrap_or(""); + if login.trim().is_empty() { + return None; + } + Some(GitHubUserSummary { + login: login.to_string(), + id: value.get("id").and_then(|v| v.as_u64()), + avatar_url: value + .get("avatar_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + name: None, + email: None, + }) +} + +fn map_pr_head_repo(value: &Value) -> Option { + let owner = value + .get("owner") + .and_then(|o| o.get("login")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let repo = value.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let url = value.get("html_url").and_then(|v| v.as_str()).unwrap_or(""); + if owner.trim().is_empty() || repo.trim().is_empty() || url.trim().is_empty() { + return None; + } + Some(GitHubPullRequestHeadRepo { + owner: owner.to_string(), + repo: repo.to_string(), + url: url.to_string(), + clone_url: value + .get("clone_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }) +} + +async fn github_get_text(url: &str, access_token: &str, accept: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(url) + .header("Accept", accept) + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "OpenChamber") + .send() + .await + .map_err(|e| e.to_string())?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("unauthorized".to_string()); + } + if !resp.status().is_success() { + return Err(format!("GitHub request failed: {}", resp.status())); + } + resp.text().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn github_prs_list( + directory: String, + page: Option, + _state: State<'_, DesktopRuntime>, +) -> Result { + let directory = directory.trim().to_string(); + if directory.is_empty() { + return Err("directory is required".to_string()); + } + + let stored = read_auth_file().await; + let Some(stored) = stored else { + return Ok(GitHubPullRequestsListResult { + connected: false, + repo: None, + prs: None, + page: None, + has_more: None, + }); + }; + if stored.access_token.trim().is_empty() { + let _ = clear_auth_file().await; + return Ok(GitHubPullRequestsListResult { + connected: false, + repo: None, + prs: None, + page: None, + has_more: None, + }); + } + + let repo = resolve_repo_from_directory(&directory).await; + let Some(repo) = repo else { + return Ok(GitHubPullRequestsListResult { + connected: true, + repo: None, + prs: Some(vec![]), + page: Some(page.unwrap_or(1).max(1) as u64), + has_more: Some(false), + }); + }; + + let page = page.unwrap_or(1).max(1); + let url = format!( + "{}/{}/{}/pulls?state=open&per_page=50&page={}", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, page + ); + + let resp = reqwest::Client::new() + .get(url) + .header("Accept", "application/vnd.github+json") + .header("Authorization", format!("Bearer {}", stored.access_token)) + .header("User-Agent", "OpenChamber") + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let _ = clear_auth_file().await; + return Ok(GitHubPullRequestsListResult { + connected: false, + repo: None, + prs: None, + page: None, + has_more: None, + }); + } + if !resp.status().is_success() { + return Err(format!("GitHub request failed: {}", resp.status())); + } + let link = resp + .headers() + .get("link") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let has_more = link.contains("rel=\"next\""); + let list = resp.json::>().await.map_err(|e| e.to_string())?; + + let prs = list + .into_iter() + .filter_map(|pr| { + let number = read_number_field(&pr, "number")?; + let head = pr.get("head")?; + let base = pr.get("base")?; + let head_ref = read_string_field(head, "ref"); + let base_ref = read_string_field(base, "ref"); + let merged = read_bool_field(&pr, "merged").unwrap_or(false); + let state_raw = read_string_field(&pr, "state"); + let state = if merged { + "merged".to_string() + } else if state_raw == "closed" { + "closed".to_string() + } else { + "open".to_string() + }; + let head_sha = read_string_field(head, "sha"); + let head_sha = if head_sha.trim().is_empty() { None } else { Some(head_sha) }; + let mergeable = read_bool_field(&pr, "mergeable"); + let mergeable_state = pr + .get("mergeable_state") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let author = pr.get("user").and_then(map_pr_user); + let head_label = head.get("label").and_then(|v| v.as_str()).map(|s| s.to_string()); + let head_repo = head.get("repo").and_then(map_pr_head_repo); + + Some(GitHubPullRequestContext { + summary: GitHubPullRequestSummary { + number, + title: read_string_field(&pr, "title"), + url: read_string_field(&pr, "html_url"), + state, + draft: read_bool_field(&pr, "draft").unwrap_or(false), + base: base_ref, + head: head_ref, + head_sha, + mergeable, + mergeable_state, + }, + author, + head_label, + head_repo, + body: None, + created_at: None, + updated_at: None, + }) + }) + .collect::>(); + + Ok(GitHubPullRequestsListResult { + connected: true, + repo: Some(repo), + prs: Some(prs), + page: Some(page as u64), + has_more: Some(has_more), + }) +} + +#[tauri::command] +pub async fn github_pr_context( + directory: String, + number: u64, + #[allow(non_snake_case)] + includeDiff: bool, + _state: State<'_, DesktopRuntime>, +) -> Result { + let directory = directory.trim().to_string(); + if directory.is_empty() { + return Err("directory is required".to_string()); + } + if number == 0 { + return Err("number is required".to_string()); + } + + let stored = read_auth_file().await; + let Some(stored) = stored else { + return Ok(GitHubPullRequestContextResult { + connected: false, + repo: None, + pr: None, + issue_comments: None, + review_comments: None, + files: None, + diff: None, + checks: None, + }); + }; + if stored.access_token.trim().is_empty() { + let _ = clear_auth_file().await; + return Ok(GitHubPullRequestContextResult { + connected: false, + repo: None, + pr: None, + issue_comments: None, + review_comments: None, + files: None, + diff: None, + checks: None, + }); + } + + let repo = resolve_repo_from_directory(&directory).await; + let Some(repo) = repo else { + return Ok(GitHubPullRequestContextResult { + connected: true, + repo: None, + pr: None, + issue_comments: None, + review_comments: None, + files: None, + diff: None, + checks: None, + }); + }; + + let pr_url = format!("{}/{}/{}/pulls/{}", API_PULLS_URL_PREFIX, repo.owner, repo.repo, number); + let pr_json = github_get_json::(&pr_url, &stored.access_token).await; + let pr_json = match pr_json { + Ok(v) => v, + Err(err) if err == "unauthorized" => { + let _ = clear_auth_file().await; + return Ok(GitHubPullRequestContextResult { + connected: false, + repo: None, + pr: None, + issue_comments: None, + review_comments: None, + files: None, + diff: None, + checks: None, + }); + } + Err(err) => return Err(err), + }; + + let head = pr_json.get("head").cloned().unwrap_or(Value::Null); + let base = pr_json.get("base").cloned().unwrap_or(Value::Null); + let head_ref = read_string_field(&head, "ref"); + let base_ref = read_string_field(&base, "ref"); + let merged = read_bool_field(&pr_json, "merged").unwrap_or(false); + let state_raw = read_string_field(&pr_json, "state"); + let state = if merged { + "merged".to_string() + } else if state_raw == "closed" { + "closed".to_string() + } else { + "open".to_string() + }; + let head_sha = read_string_field(&head, "sha"); + let head_sha = if head_sha.trim().is_empty() { None } else { Some(head_sha) }; + + let pr = GitHubPullRequestContext { + summary: GitHubPullRequestSummary { + number, + title: read_string_field(&pr_json, "title"), + url: read_string_field(&pr_json, "html_url"), + state, + draft: read_bool_field(&pr_json, "draft").unwrap_or(false), + base: base_ref, + head: head_ref, + head_sha, + mergeable: read_bool_field(&pr_json, "mergeable"), + mergeable_state: pr_json + .get("mergeable_state") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }, + author: pr_json.get("user").and_then(map_pr_user), + head_label: head.get("label").and_then(|v| v.as_str()).map(|s| s.to_string()), + head_repo: head.get("repo").and_then(map_pr_head_repo), + body: pr_json.get("body").and_then(|v| v.as_str()).map(|s| s.to_string()), + created_at: pr_json.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()), + updated_at: pr_json.get("updated_at").and_then(|v| v.as_str()).map(|s| s.to_string()), + }; + + let issue_comments_url = format!( + "{}/{}/{}/issues/{}/comments?per_page=100", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, number + ); + let issue_comments = github_get_json::>(&issue_comments_url, &stored.access_token).await?; + let issue_comments = issue_comments + .into_iter() + .map(|c| GitHubIssueComment { + id: c.id, + url: c.html_url, + body: c.body.unwrap_or_default(), + author: c.user.as_ref().map(map_issue_user), + created_at: c.created_at, + updated_at: c.updated_at, + }) + .collect::>(); + + let review_comments_url = format!( + "{}/{}/{}/pulls/{}/comments?per_page=100", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, number + ); + let review_comments = github_get_json::>(&review_comments_url, &stored.access_token).await?; + let review_comments = review_comments + .into_iter() + .map(|c| GitHubPullRequestReviewComment { + id: c.id, + url: c.html_url, + body: c.body.unwrap_or_default(), + author: c.user.as_ref().map(map_issue_user), + path: c.path, + line: c.line, + position: c.position, + created_at: c.created_at, + updated_at: c.updated_at, + }) + .collect::>(); + + let files_url = format!( + "{}/{}/{}/pulls/{}/files?per_page=100", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, number + ); + let files = github_get_json::>(&files_url, &stored.access_token).await?; + let files = files + .into_iter() + .map(|f| GitHubPullRequestFile { + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + patch: f.patch, + }) + .collect::>(); + + // checks summary (same as github_pr_status) + let mut checks: Option = None; + if let Some(ref sha) = pr.summary.head_sha { + let check_runs_url = format!( + "{}/{}/{}/commits/{}/check-runs", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, sha + ); + if let Ok(runs) = github_get_json::(&check_runs_url, &stored.access_token).await { + if !runs.check_runs.is_empty() { + let mut success = 0; + let mut failure = 0; + let mut pending = 0; + for run in runs.check_runs.iter() { + let status = run.status.as_deref().unwrap_or(""); + let conclusion = run.conclusion.as_deref().unwrap_or(""); + if status == "queued" || status == "in_progress" { + pending += 1; + continue; + } + if conclusion.is_empty() { + pending += 1; + continue; + } + if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" { + success += 1; + } else { + failure += 1; + } + } + let total = success + failure + pending; + let state = if failure > 0 { + "failure" + } else if pending > 0 { + "pending" + } else if total > 0 { + "success" + } else { + "unknown" + }; + checks = Some(GitHubChecksSummary { + state: state.to_string(), + total, + success, + failure, + pending, + }); + } + } + + if checks.is_none() { + let status_url = format!( + "{}/{}/{}/commits/{}/status", + API_PULLS_URL_PREFIX, repo.owner, repo.repo, sha + ); + if let Ok(status) = github_get_json::(&status_url, &stored.access_token).await { + let mut success = 0; + let mut failure = 0; + let mut pending = 0; + for s in status.statuses.iter() { + match s.state.as_str() { + "success" => success += 1, + "failure" | "error" => failure += 1, + "pending" => pending += 1, + _ => {} + } + } + let total = success + failure + pending; + let state = if failure > 0 { + "failure" + } else if pending > 0 { + "pending" + } else if total > 0 { + "success" + } else { + "unknown" + }; + checks = Some(GitHubChecksSummary { + state: state.to_string(), + total, + success, + failure, + pending, + }); + } + } + } + + let diff = if includeDiff { + let diff_text = github_get_text(&pr_url, &stored.access_token, "application/vnd.github.v3.diff").await; + match diff_text { + Ok(v) => Some(v), + Err(err) if err == "unauthorized" => { + let _ = clear_auth_file().await; + return Ok(GitHubPullRequestContextResult { + connected: false, + repo: None, + pr: None, + issue_comments: None, + review_comments: None, + files: None, + diff: None, + checks: None, + }); + } + Err(_) => None, + } + } else { + None + }; + + Ok(GitHubPullRequestContextResult { + connected: true, + repo: Some(repo), + pr: Some(pr), + issue_comments: Some(issue_comments), + review_comments: Some(review_comments), + files: Some(files), + diff, + checks, + }) +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index c87f4b18..c69ae800 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -47,6 +47,7 @@ use commands::logs::fetch_desktop_logs; use commands::github::{ github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me, github_issue_comments, github_issue_get, github_issues_list, + github_pr_context, github_prs_list, github_pr_create, github_pr_merge, github_pr_ready, github_pr_status, }; use commands::notifications::desktop_notify; @@ -907,6 +908,8 @@ fn main() { github_pr_create, github_pr_merge, github_pr_ready, + github_prs_list, + github_pr_context, github_issues_list, github_issue_get, github_issue_comments, diff --git a/packages/desktop/src/api/github.ts b/packages/desktop/src/api/github.ts index a3c05219..b331f359 100644 --- a/packages/desktop/src/api/github.ts +++ b/packages/desktop/src/api/github.ts @@ -4,6 +4,8 @@ import type { GitHubIssueCommentsResult, GitHubIssueGetResult, GitHubIssuesListResult, + GitHubPullRequestContextResult, + GitHubPullRequestsListResult, GitHubPullRequest, GitHubPullRequestCreateInput, GitHubPullRequestMergeInput, @@ -63,9 +65,9 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({ return safeInvoke('github_pr_ready', payload, { timeout: 20000 }); }, - async issuesList(directory: string): Promise { + async issuesList(directory: string, options?: { page?: number }): Promise { const { safeInvoke } = await import('../lib/tauriCallbackManager'); - return safeInvoke('github_issues_list', { directory }, { timeout: 20000 }); + return safeInvoke('github_issues_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 }); }, async issueGet(directory: string, number: number): Promise { @@ -77,4 +79,18 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({ const { safeInvoke } = await import('../lib/tauriCallbackManager'); return safeInvoke('github_issue_comments', { directory, number }, { timeout: 20000 }); }, + + async prsList(directory: string, options?: { page?: number }): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke('github_prs_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 }); + }, + + async prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise { + const { safeInvoke } = await import('../lib/tauriCallbackManager'); + return safeInvoke( + 'github_pr_context', + { directory, number, includeDiff: Boolean(options?.includeDiff) }, + { timeout: 30000 } + ); + }, }); diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index 41d3f25d..11934cb8 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -25,9 +25,9 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useMessageStore } from '@/stores/messageStore'; import { useContextStore } from '@/stores/contextStore'; import { opencodeClient } from '@/lib/opencode/client'; -import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator'; -import { createBranch } from '@/lib/gitApi'; -import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult } from '@/lib/api/types'; +import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; +import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; +import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary } from '@/lib/api/types'; const parseIssueNumber = (value: string): number | null => { const trimmed = value.trim(); @@ -48,15 +48,6 @@ const parseIssueNumber = (value: string): number | null => { return null; }; -const sanitizeSlug = (value: string): string => { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/^[-_]+|[-_]+$/g, '') - .slice(0, 80); -}; - const buildIssueContextText = (args: { repo: GitHubIssuesListResult['repo'] | undefined; issue: GitHubIssue; @@ -86,8 +77,12 @@ export function GitHubIssuePickerDialog({ const [query, setQuery] = React.useState(''); const [createInWorktree, setCreateInWorktree] = React.useState(false); const [result, setResult] = React.useState(null); + const [issues, setIssues] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); const [startingIssueNumber, setStartingIssueNumber] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); const [error, setError] = React.useState(null); const refresh = React.useCallback(async () => { @@ -105,8 +100,11 @@ export function GitHubIssuePickerDialog({ setIsLoading(true); setError(null); try { - const next = await github.issuesList(projectDirectory); + const next = await github.issuesList(projectDirectory, { page: 1 }); setResult(next); + setIssues(next.issues ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); if (next.connected === false) { setError(null); } @@ -117,6 +115,28 @@ export function GitHubIssuePickerDialog({ } }, [github, projectDirectory]); + const loadMore = React.useCallback(async () => { + if (!projectDirectory) return; + if (!github?.issuesList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore) return; + + setIsLoadingMore(true); + try { + const nextPage = page + 1; + const next = await github.issuesList(projectDirectory, { page: nextPage }); + setResult(next); + setIssues((prev) => [...prev, ...(next.issues ?? [])]); + setPage(next.page ?? nextPage); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to load more issues', { description: message }); + } finally { + setIsLoadingMore(false); + } + }, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]); + React.useEffect(() => { if (!open) { setQuery(''); @@ -124,13 +144,15 @@ export function GitHubIssuePickerDialog({ setStartingIssueNumber(null); setError(null); setResult(null); + setIssues([]); + setPage(1); + setHasMore(false); setIsLoading(false); return; } void refresh(); }, [open, refresh]); - const issues = React.useMemo(() => result?.issues ?? [], [result?.issues]); const connected = Boolean(result?.connected); const repoUrl = result?.repo?.url ?? null; @@ -207,29 +229,6 @@ export function GitHubIssuePickerDialog({ return settingsDefaultVariant; }, []); - const buildUniqueIssueBranchName = React.useCallback( - async (issue: GitHubIssue) => { - const titleSlug = sanitizeSlug(issue.title); - const base = titleSlug ? `issue-${issue.number}-${titleSlug}` : `issue-${issue.number}`; - const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined; - - for (let attempt = 0; attempt < 6; attempt += 1) { - const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`; - try { - const created = await createBranch(projectDirectory || '', candidate, startPoint); - if (created?.success) { - return candidate; - } - } catch { - // try next - } - } - - throw new Error('Failed to create issue branch'); - }, - [baseBranch, projectDirectory] - ); - const startSession = React.useCallback(async (issueNumber: number) => { if (!projectDirectory) { toast.error('No active project'); @@ -270,8 +269,12 @@ export function GitHubIssuePickerDialog({ const sessionId = await (async () => { if (createInWorktree) { - const branchName = await buildUniqueIssueBranchName(issue); - const created = await createWorktreeSessionForBranch(projectDirectory, branchName); + const preferred = `issue-${issue.number}-${generateBranchSlug()}`; + const created = await createWorktreeSessionForNewBranch( + projectDirectory, + preferred, + baseBranch || 'main' + ); if (!created?.id) { throw new Error('Failed to create worktree session'); } @@ -350,8 +353,42 @@ export function GitHubIssuePickerDialog({ } } - const promptText = - 'Review this GitHub issue. Summarize requirements + unknowns, ask clarifying questions, gather any needed code context, then propose a plan and next actions. Do not implement until I confirm.'; + const visiblePromptText = 'Review this issue using the provided issue context: title, body, labels, assignees, comments, metadata.'; + const instructionsText = `Review this issue using the provided issue context. + +Process: +- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: . +- Gather any needed repository context (code, config, docs) to validate assumptions. +- After gathering, if anything is still unclear or cannot be verified, do not speculate—state what’s missing and ask targeted questions. + +Output rules: +- Compact output; pick ONE template below and omit the others. +- No emojis. No code snippets. No fenced blocks. +- Short inline code identifiers allowed. +- Reference evidence with file paths and line ranges when applicable; if exact lines aren’t available, cite the file and say “approx” + why. +- Keep the entire response under ~300 words. + +Templates (choose one): +Bug: +- Summary (1-2 sentences) +- Likely cause (max 2) +- Repro/diagnostics needed (max 3) +- Fix approach (max 4 steps) +- Verification (max 3) + +Feature: +- Summary (1-2 sentences) +- Requirements (max 4) +- Unknowns/questions (max 4) +- Proposed plan (max 5 steps) +- Verification (max 3) + +Question/Support: +- Summary (1-2 sentences) +- Answer/guidance (max 6 lines) +- Missing info (max 4) + +Do not implement changes until I confirm; end with: “Next actions: <1 sentence>”.`; const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); void opencodeClient.sendMessage({ @@ -360,8 +397,11 @@ export function GitHubIssuePickerDialog({ modelID, agent: agentName, variant, - text: promptText, - additionalParts: [{ text: contextText, synthetic: true }], + text: visiblePromptText, + additionalParts: [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], }).catch((e) => { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to send issue context', { @@ -376,17 +416,7 @@ export function GitHubIssuePickerDialog({ } finally { setStartingIssueNumber(null); } - }, [ - buildUniqueIssueBranchName, - createInWorktree, - github, - onOpenChange, - projectDirectory, - resolveDefaultAgentName, - resolveDefaultModelSelection, - resolveDefaultVariant, - startingIssueNumber, - ]); + }, [createInWorktree, github, onOpenChange, projectDirectory, baseBranch, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]); return ( @@ -493,6 +523,29 @@ export function GitHubIssuePickerDialog({ ))} + + {hasMore && connected && projectDirectory && github ? ( +
+ +
+ ) : null}
diff --git a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx new file mode 100644 index 00000000..f49a8c83 --- /dev/null +++ b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx @@ -0,0 +1,640 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui'; +import { + RiCheckboxBlankLine, + RiCheckboxLine, + RiExternalLinkLine, + RiGitPullRequestLine, + RiLoader4Line, + RiSearchLine, +} from '@remixicon/react'; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useMessageStore } from '@/stores/messageStore'; +import { useContextStore } from '@/stores/contextStore'; +import { opencodeClient } from '@/lib/opencode/client'; +import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator'; +import { gitFetch } from '@/lib/gitApi'; +import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types'; + +const parsePullRequestNumber = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\b|\/|$)/i); + if (urlMatch) { + const parsed = Number(urlMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + const hashMatch = trimmed.match(/^#?(\d+)$/); + if (hashMatch) { + const parsed = Number(hashMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + return null; +}; + +const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => { + return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + +export function GitHubPullRequestPickerDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { github } = useRuntimeAPIs(); + const activeProject = useProjectsStore((state) => state.getActiveProject()); + + const projectDirectory = activeProject?.path ?? null; + + const [query, setQuery] = React.useState(''); + const [createInWorktree, setCreateInWorktree] = React.useState(false); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [result, setResult] = React.useState(null); + const [prs, setPrs] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [startingNumber, setStartingNumber] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + + const refresh = React.useCallback(async () => { + if (!projectDirectory) { + setResult(null); + setError('No active project'); + return; + } + if (!github?.prsList) { + setResult(null); + setError('GitHub runtime API unavailable'); + return; + } + + setIsLoading(true); + setError(null); + try { + const next = await github.prsList(projectDirectory, { page: 1 }); + setResult(next); + setPrs(next.prs ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + if (next.connected === false) { + setError(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, [github, projectDirectory]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory) return; + if (!github?.prsList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore) return; + + setIsLoadingMore(true); + try { + const nextPage = page + 1; + const next = await github.prsList(projectDirectory, { page: nextPage }); + setResult(next); + setPrs((prev) => [...prev, ...(next.prs ?? [])]); + setPage(next.page ?? nextPage); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to load more PRs', { description: message }); + } finally { + setIsLoadingMore(false); + } + }, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setCreateInWorktree(false); + setIncludeDiff(false); + setResult(null); + setPrs([]); + setPage(1); + setHasMore(false); + setStartingNumber(null); + setIsLoading(false); + setError(null); + return; + } + void refresh(); + }, [open, refresh]); + + const connected = Boolean(result?.connected); + const repoUrl = result?.repo?.url ?? null; + + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return prs; + return prs.filter((pr) => { + if (String(pr.number) === q.replace(/^#/, '')) return true; + return pr.title.toLowerCase().includes(q); + }); + }, [prs, query]); + + const directNumber = React.useMemo(() => parsePullRequestNumber(query), [query]); + + const resolveDefaultAgentName = React.useCallback((): string | undefined => { + const configState = useConfigStore.getState(); + const visibleAgents = configState.getVisibleAgents(); + + if (configState.settingsDefaultAgent) { + const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); + if (settingsAgent) { + return settingsAgent.name; + } + } + + return visibleAgents.find((agent) => agent.name === 'build')?.name || visibleAgents[0]?.name; + }, []); + + const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => { + const configState = useConfigStore.getState(); + const settingsDefaultModel = configState.settingsDefaultModel; + if (!settingsDefaultModel) return null; + + const parts = settingsDefaultModel.split('/'); + if (parts.length !== 2) return null; + const [providerID, modelID] = parts; + if (!providerID || !modelID) return null; + + const modelMetadata = configState.getModelMetadata(providerID, modelID); + if (!modelMetadata) return null; + return { providerID, modelID }; + }, []); + + const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { + const configState = useConfigStore.getState(); + const settingsDefaultVariant = configState.settingsDefaultVariant; + if (!settingsDefaultVariant) return undefined; + + const provider = configState.providers.find((p) => p.id === providerID); + const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as + | { variants?: Record } + | undefined; + const variants = model?.variants; + if (!variants) return undefined; + if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return undefined; + return settingsDefaultVariant; + }, []); + + const createPrWorktreeSession = React.useCallback(async ( + baseRepo: GitHubPullRequestsListResult['repo'] | undefined, + pr: GitHubPullRequestSummary, + ): Promise<{ id: string } | null> => { + if (!projectDirectory) return null; + const headRef = pr.head; + const headRepo = pr.headRepo; + if (!headRef) { + throw new Error('PR head ref missing'); + } + + const isFork = Boolean( + headRepo?.owner && headRepo?.repo && + baseRepo?.owner && baseRepo?.repo && + (headRepo.owner !== baseRepo.owner || headRepo.repo !== baseRepo.repo) + ); + + const fetchRemote = isFork + ? (headRepo?.cloneUrl || headRepo?.url || '') + : 'origin'; + if (!fetchRemote) { + throw new Error('PR head remote URL missing'); + } + + const fetchRef = `refs/heads/${headRef}`; + const fetchResult = await gitFetch(projectDirectory, { remote: fetchRemote, branch: fetchRef }); + if (!fetchResult?.success) { + throw new Error('Failed to fetch PR head'); + } + + const preferredBranch = pr.head; + const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, 'FETCH_HEAD'); + if (!session?.id) { + throw new Error('Failed to create PR worktree session'); + } + return { id: session.id }; + }, [projectDirectory]); + + const startSession = React.useCallback(async (number: number) => { + if (!projectDirectory) { + toast.error('No active project'); + return; + } + if (!github?.prContext) { + toast.error('GitHub runtime API unavailable'); + return; + } + if (startingNumber) return; + setStartingNumber(number); + try { + const prContext = await github.prContext(projectDirectory, number, { includeDiff }); + if (prContext.connected === false) { + toast.error('GitHub not connected'); + return; + } + if (!prContext.repo) { + toast.error('Repo not resolvable', { description: 'origin remote must be a GitHub URL' }); + return; + } + if (!prContext.pr) { + toast.error('PR not found'); + return; + } + + const pr = prContext.pr; + const sessionTitle = `#${pr.number} ${pr.title}`.trim(); + + const sessionId = await (async () => { + if (createInWorktree) { + try { + const worktreeSession = await createPrWorktreeSession(prContext.repo, pr); + return worktreeSession?.id || null; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + toast.error('PR worktree failed', { description: msg }); + // fall back to normal session + } + } + const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null); + return session?.id || null; + })(); + + if (!sessionId) { + throw new Error('Failed to create session'); + } + + void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); + try { + useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); + } catch { + // ignore + } + + onOpenChange(false); + + const configState = useConfigStore.getState(); + const lastUsedProvider = useMessageStore.getState().lastUsedProvider; + const defaultModel = resolveDefaultModelSelection(); + const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; + const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; + const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; + if (!providerID || !modelID) { + toast.error('No model selected'); + return; + } + + const variant = resolveDefaultVariant(providerID, modelID); + try { + useContextStore.getState().saveSessionModelSelection(sessionId, providerID, modelID); + } catch { + // ignore + } + + if (agentName) { + try { + configState.setAgent(agentName); + } catch { + // ignore + } + try { + useContextStore.getState().saveSessionAgentSelection(sessionId, agentName); + } catch { + // ignore + } + try { + useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerID, modelID); + } catch { + // ignore + } + if (variant !== undefined) { + try { + configState.setCurrentVariant(variant); + } catch { + // ignore + } + try { + useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerID, modelID, variant); + } catch { + // ignore + } + } + } + + const visiblePromptText = 'Review this pull request using the provided PR context: description, comments, files, diff, checks.'; + const instructionsText = `Before reporting issues: +- First identify the PR intent (what it’s trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep. +- Gather any needed repository context (code, config, docs) to validate assumptions. +- No speculation: if something is unclear or cannot be verified, say what’s missing and ask for it instead of guessing. + +Output rules: +- Start with a 1-2 sentence summary. +- Provide a single concise PR review comment. +- No emojis. No code snippets. No fenced blocks. +- Short inline code identifiers allowed, but no snippets or fenced blocks. +- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren’t available, cite the file and say “approx” + why. +- Keep the entire comment under ~300 words. + +Report: +- Must-fix issues (blocking) — brief why and a one-line action each. +- Nice-to-have improvements (optional) — brief why and a one-line action each. + +Quality & safety (general): +- Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks. +- Call out missing tests/verification steps and suggest the minimal validation needed. +- Note readability/maintainability issues when they materially affect future changes. + +Applicability (only if relevant): +- If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why. + +Architecture: +- Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility). + +Precedence: +- If local precedent conflicts with best practices, state it and suggest a follow-up task. + +Do not implement changes until I confirm; end with a short “Next actions” sentence describing the recommended plan. + +Format exactly: +Must-fix: +- — Action: +Nice-to-have: +- — Action: +If no issues, write: +Must-fix: +- None +Nice-to-have: +- None`; + const contextText = buildPullRequestContextText(prContext); + + void opencodeClient.sendMessage({ + id: sessionId, + providerID, + modelID, + agent: agentName, + variant, + text: visiblePromptText, + additionalParts: [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + }).catch((e) => { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to send PR context', { description: message }); + }); + + toast.success('Session created from PR'); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error('Failed to start session', { description: message }); + } finally { + setStartingNumber(null); + } + }, [ + createInWorktree, + createPrWorktreeSession, + github, + includeDiff, + onOpenChange, + projectDirectory, + resolveDefaultAgentName, + resolveDefaultModelSelection, + resolveDefaultVariant, + startingNumber, + ]); + + return ( + + + + + + New Session From GitHub PR + + + Seeds a new session with hidden PR context (title/body/comments/files/checks). + + + +
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ {!projectDirectory ? ( +
No active project selected.
+ ) : null} + + {!github ? ( +
GitHub runtime API unavailable.
+ ) : null} + + {isLoading ? ( +
+ + Loading pull requests... +
+ ) : null} + + {connected === false ? ( +
GitHub not connected.
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directNumber && projectDirectory && github && connected ? ( +
void startSession(directNumber)} + > + # +

+ Use PR #{directNumber} +

+
+ {startingNumber === directNumber ? ( + + ) : null} +
+
+ ) : null} + + {filtered.length === 0 && !isLoading && connected && github && projectDirectory ? ( +
{query ? 'No PRs found' : 'No open PRs found'}
+ ) : null} + + {filtered.map((pr) => ( +
void startSession(pr.number)} + > + #{pr.number} +

{pr.title}

+
+ {startingNumber === pr.number ? ( + + ) : ( + e.stopPropagation()} + aria-label="Open in GitHub" + > + + + )} +
+
+ ))} + + {hasMore && connected && projectDirectory && github ? ( +
+ +
+ ) : null} +
+ +
+

Actions

+
+
setCreateInWorktree((v) => !v)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setCreateInWorktree((v) => !v); + } + }} + > + + Create session in PR worktree +
+ +
setIncludeDiff((v) => !v)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setIncludeDiff((v) => !v); + } + }} + > + + Include full diff +
+ +
+ {repoUrl ? ( + + ) : null} + +
+
+ +
+ ); +} diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 96bb4900..3d841495 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -39,6 +39,7 @@ import { RiFileCopyLine, RiFolderAddLine, RiGitBranchLine, + RiGitPullRequestLine, RiGitRepositoryLine, RiLinkUnlinkM, @@ -65,6 +66,7 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { isVSCodeRuntime } from '@/lib/desktop'; import { BranchPickerDialog } from './BranchPickerDialog'; import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog'; +import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; @@ -143,6 +145,7 @@ interface SortableProjectItemProps { onNewWorktreeSession?: () => void; onOpenBranchPicker?: () => void; onNewSessionFromGitHubIssue?: () => void; + onNewSessionFromGitHubPR?: () => void; onOpenMultiRunLauncher: () => void; onClose: () => void; sentinelRef: (el: HTMLDivElement | null) => void; @@ -168,6 +171,7 @@ const SortableProjectItem: React.FC = ({ onNewWorktreeSession, onOpenBranchPicker, onNewSessionFromGitHubIssue, + onNewSessionFromGitHubPR, onOpenMultiRunLauncher, onClose, sentinelRef, @@ -284,6 +288,12 @@ const SortableProjectItem: React.FC = ({ New session from GitHub issue )} + {isRepo && !hideDirectoryControls && onNewSessionFromGitHubPR && ( + + + New session from GitHub PR + + )} {isRepo && !hideDirectoryControls && ( @@ -411,6 +421,7 @@ export const SessionSidebar: React.FC = ({ const [hoveredProjectId, setHoveredProjectId] = React.useState(null); const [branchPickerOpen, setBranchPickerOpen] = React.useState(false); const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); + const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false); const [activeDragId, setActiveDragId] = React.useState(null); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState>(new Set()); const [openMenuSessionId, setOpenMenuSessionId] = React.useState(null); @@ -1653,6 +1664,16 @@ export const SessionSidebar: React.FC = ({ } setIssuePickerOpen(true); }} + onNewSessionFromGitHubPR={() => { + if (projectKey !== activeProjectId) { + setActiveProject(projectKey); + } + setActiveMainTab('chat'); + if (mobileVariant) { + setSessionSwitcherOpen(false); + } + setPullRequestPickerOpen(true); + }} onOpenMultiRunLauncher={() => { if (projectKey !== activeProjectId) { setActiveProject(projectKey); @@ -1699,6 +1720,11 @@ export const SessionSidebar: React.FC = ({ open={issuePickerOpen} onOpenChange={setIssuePickerOpen} /> + +
); }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7c942d0e..1fc881fd 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -522,6 +522,62 @@ export type GitHubPullRequest = { mergeableState?: string | null; }; +export type GitHubPullRequestHeadRepo = { + owner: string; + repo: string; + url: string; + cloneUrl?: string; +}; + +export type GitHubPullRequestSummary = GitHubPullRequest & { + author?: GitHubUserSummary | null; + body?: string; + createdAt?: string; + updatedAt?: string; + headLabel?: string; + headRepo?: GitHubPullRequestHeadRepo | null; +}; + +export type GitHubPullRequestFile = { + filename: string; + status?: string; + additions?: number; + deletions?: number; + changes?: number; + patch?: string; +}; + +export type GitHubPullRequestReviewComment = { + id: number; + url: string; + body: string; + author?: GitHubUserSummary | null; + path?: string; + line?: number | null; + position?: number | null; + createdAt?: string; + updatedAt?: string; +}; + +export type GitHubPullRequestsListResult = { + connected: boolean; + repo?: GitHubRepoRef | null; + prs?: GitHubPullRequestSummary[]; + page?: number; + hasMore?: boolean; +}; + +export type GitHubPullRequestContextResult = { + connected: boolean; + repo?: GitHubRepoRef | null; + pr?: GitHubPullRequestSummary | null; + issueComments?: GitHubIssueComment[]; + reviewComments?: GitHubPullRequestReviewComment[]; + files?: GitHubPullRequestFile[]; + diff?: string; + checks?: GitHubChecksSummary | null; +}; + export type GitHubPullRequestStatus = { connected: boolean; repo?: GitHubRepoRef | null; @@ -594,6 +650,8 @@ export type GitHubIssuesListResult = { connected: boolean; repo?: GitHubRepoRef | null; issues?: GitHubIssueSummary[]; + page?: number; + hasMore?: boolean; }; export type GitHubIssueGetResult = { @@ -640,7 +698,10 @@ export interface GitHubAPI { prMerge(payload: GitHubPullRequestMergeInput): Promise; prReady(payload: GitHubPullRequestReadyInput): Promise; - issuesList(directory: string): Promise; + prsList(directory: string, options?: { page?: number }): Promise; + prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise; + + issuesList(directory: string, options?: { page?: number }): Promise; issueGet(directory: string, number: number): Promise; issueComments(directory: string, number: number): Promise; } diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index 28626a44..431fd347 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -438,3 +438,197 @@ export async function createWorktreeSessionForBranch( isCreatingWorktreeSession = false; } } + +/** + * Create a worktree session for a new branch (created at startPoint). + * This avoids checking out the branch in the main worktree. + */ +export async function createWorktreeSessionForNewBranch( + projectDirectory: string, + preferredBranchName: string, + startPoint: string, + options?: { allowSuffix?: boolean } +): Promise<{ id: string; branch: string } | null> { + if (isCreatingWorktreeSession) { + return null; + } + + let isGitRepo = false; + try { + isGitRepo = await checkIsGitRepository(projectDirectory); + } catch { + // ignore + } + + if (!isGitRepo) { + toast.error('Not a Git repository', { + description: 'Worktrees can only be created in Git repositories.', + }); + return null; + } + + isCreatingWorktreeSession = true; + startConfigUpdate('Creating worktree session...'); + + try { + const start = startPoint?.trim() || 'HEAD'; + const base = preferredBranchName?.trim(); + if (!base) { + throw new Error('Branch name is required'); + } + + let lastError: unknown = null; + const allowSuffix = options?.allowSuffix !== false; + const maxAttempts = allowSuffix ? 6 : 1; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`; + try { + const worktreeSlug = sanitizeWorktreeSlug(candidate); + const metadata = await createWorktree({ + projectDirectory, + worktreeSlug, + branch: candidate, + createBranch: true, + startPoint: start, + }); + + const status = await getWorktreeStatus(metadata.path).catch(() => undefined); + const createdMetadata = status ? { ...metadata, status } : metadata; + + const sessionStore = useSessionStore.getState(); + const session = await sessionStore.createSession(undefined, metadata.path); + if (!session) { + await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined); + throw new Error('Could not create a session for the worktree.'); + } + + const configState = useConfigStore.getState(); + sessionStore.initializeNewOpenChamberSession(session.id, configState.agents); + sessionStore.setSessionDirectory(session.id, metadata.path); + sessionStore.setWorktreeMetadata(session.id, createdMetadata); + + // Apply default agent/model/variant settings (reuse same logic as createWorktreeSessionForBranch) + try { + const visibleAgents = configState.getVisibleAgents(); + let agentName: string | undefined; + if (configState.settingsDefaultAgent) { + const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); + if (settingsAgent) { + agentName = settingsAgent.name; + } + } + if (!agentName) { + agentName = + visibleAgents.find((agent) => agent.name === 'build')?.name || + visibleAgents[0]?.name; + } + + if (agentName) { + configState.setAgent(agentName); + useContextStore.getState().saveSessionAgentSelection(session.id, agentName); + + const settingsDefaultModel = configState.settingsDefaultModel; + if (settingsDefaultModel) { + const parts = settingsDefaultModel.split('/'); + if (parts.length === 2) { + const [providerId, modelId] = parts; + const modelMetadata = configState.getModelMetadata(providerId, modelId); + if (modelMetadata) { + useContextStore.getState().saveSessionModelSelection(session.id, providerId, modelId); + useContextStore.getState().saveAgentModelForSession(session.id, agentName, providerId, modelId); + + const settingsDefaultVariant = configState.settingsDefaultVariant; + if (settingsDefaultVariant) { + const provider = configState.providers.find((p) => p.id === providerId); + const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelId) as + | { variants?: Record } + | undefined; + const variants = model?.variants; + if (variants && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { + configState.setCurrentVariant(settingsDefaultVariant); + useContextStore + .getState() + .saveAgentModelVariantForSession(session.id, agentName, providerId, modelId, settingsDefaultVariant); + } + } + } + } + } + } + } catch { + // ignore + } + + useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false }); + try { + await sessionStore.loadSessions(); + } catch { + // ignore + } + + // Get and run setup commands + const setupCommands = await getWorktreeSetupCommands(projectDirectory); + const commandsToRun = setupCommands.filter((cmd) => cmd.trim().length > 0); + + if (commandsToRun.length > 0) { + toast.success('Worktree created', { + description: `Branch: ${candidate}. Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`, + }); + + // Run setup commands in background + runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun) + .then((result) => { + if (result.success) { + toast.success('Setup commands completed', { + description: `All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`, + }); + } else { + const failed = result.results.filter((r) => !r.success); + const succeeded = result.results.filter((r) => r.success); + toast.error('Setup commands failed', { + description: + `${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` + + (succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : ''), + }); + } + }) + .catch(() => { + toast.error('Setup commands failed', { + description: 'Could not execute setup commands.', + }); + }); + } else { + toast.success('Worktree created', { + description: `Branch: ${candidate}`, + }); + } + + return { id: session.id, branch: candidate }; + } catch (error) { + lastError = error; + } + } + + const message = lastError instanceof Error ? lastError.message : 'Failed to create worktree session'; + toast.error('Failed to create worktree', { + description: message, + }); + return null; + } finally { + finishConfigUpdate(); + isCreatingWorktreeSession = false; + } +} + +/** + * Same as createWorktreeSessionForNewBranch, but does NOT suffix the branch name. + * Use when the worktree must be created on an exact branch name (e.g. PR head ref). + */ +export async function createWorktreeSessionForNewBranchExact( + projectDirectory: string, + branchName: string, + startPoint: string +): Promise<{ id: string; branch: string } | null> { + return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { allowSuffix: false }); +} diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 3e3d503f..d7f2cdc0 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -35,6 +35,11 @@ import { listIssues, } from './githubIssues'; +import { + getPullRequestContext, + listPullRequests, +} from './githubPulls'; + export interface BridgeRequest { id: string; type: string; @@ -1185,11 +1190,12 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo return { id, type, success: true, data: { connected: false } }; } const directory = readStringField(payload, 'directory'); + const page = readNumberField(payload, 'page') ?? 1; if (!directory) { return { id, type, success: false, error: 'directory is required' }; } try { - const result = await listIssues(stored.accessToken, directory); + const result = await listIssues(stored.accessToken, directory, page); if (result.connected === false) { await clearGitHubAuth(context); } @@ -1248,6 +1254,55 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } } + case 'api:github/pulls:list': { + const context = ctx?.context; + if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; + const stored = await readGitHubAuth(context); + if (!stored?.accessToken) { + return { id, type, success: true, data: { connected: false } }; + } + const directory = readStringField(payload, 'directory'); + const page = readNumberField(payload, 'page') ?? 1; + if (!directory) { + return { id, type, success: false, error: 'directory is required' }; + } + try { + const result = await listPullRequests(stored.accessToken, directory, page); + if (result.connected === false) { + await clearGitHubAuth(context); + } + return { id, type, success: true, data: result }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: message }; + } + } + + case 'api:github/pulls:context': { + const context = ctx?.context; + if (!context) return { id, type, success: false, error: 'Missing VS Code context' }; + const stored = await readGitHubAuth(context); + if (!stored?.accessToken) { + return { id, type, success: true, data: { connected: false } }; + } + const directory = readStringField(payload, 'directory'); + const number = readNumberField(payload, 'number') ?? 0; + const includeDiff = readBooleanField(payload, 'includeDiff') ?? false; + if (!directory || !number) { + return { id, type, success: false, error: 'directory and number are required' }; + } + try { + const result = await getPullRequestContext(stored.accessToken, directory, number, includeDiff); + if (result.connected === false) { + await clearGitHubAuth(context); + } + return { id, type, success: true, data: result }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: message }; + } + } + case 'api:config/reload': { await ctx?.manager?.restart(); return { id, type, success: true, data: { restarted: true } }; diff --git a/packages/vscode/src/githubIssues.ts b/packages/vscode/src/githubIssues.ts index 1fa22852..22cf93a3 100644 --- a/packages/vscode/src/githubIssues.ts +++ b/packages/vscode/src/githubIssues.ts @@ -17,6 +17,8 @@ type GitHubIssuesListResult = { author?: { login: string; id?: number; avatarUrl?: string; name?: string; email?: string } | null; labels?: Array<{ name: string; color?: string }>; }>; + page?: number; + hasMore?: boolean; }; type GitHubIssueGetResult = { @@ -102,6 +104,7 @@ const mapLabels = (raw: unknown): Array<{ name: string; color?: string }> => { export const listIssues = async ( accessToken: string, directory: string, + page: number = 1, ): Promise => { const repo = await resolveRepoFromDirectory(directory); if (!repo) { @@ -111,12 +114,16 @@ export const listIssues = async ( const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues`); url.searchParams.set('state', 'open'); url.searchParams.set('per_page', '50'); + url.searchParams.set('page', String(page)); const resp = await githubFetch(url.toString(), accessToken); if (resp.status === 401) { return { connected: false }; } + const link = resp.headers.get('link') || ''; + const hasMore = /rel="next"/.test(link); + const json = await jsonOrNull(resp); if (!resp.ok || !Array.isArray(json)) { throw new Error('Failed to load issues'); @@ -140,7 +147,7 @@ export const listIssues = async ( }) .filter(Boolean) as GitHubIssuesListResult['issues']; - return { connected: true, repo, issues: issues || [] }; + return { connected: true, repo, issues: issues || [], page, hasMore }; }; export const getIssue = async ( diff --git a/packages/vscode/src/githubPulls.ts b/packages/vscode/src/githubPulls.ts new file mode 100644 index 00000000..0dd0d21e --- /dev/null +++ b/packages/vscode/src/githubPulls.ts @@ -0,0 +1,373 @@ +import { resolveRepoFromDirectory } from './githubPr'; + +const API_BASE = 'https://api.github.com'; + +type JsonRecord = Record; + +type GitHubRepoRef = { owner: string; repo: string; url: string }; + +type GitHubUserSummary = { login: string; id?: number; avatarUrl?: string; name?: string; email?: string }; + +type GitHubChecksSummary = { + state: 'success' | 'failure' | 'pending' | 'unknown'; + total: number; + success: number; + failure: number; + pending: number; +}; + +type GitHubPullRequestHeadRepo = { owner: string; repo: string; url: string; cloneUrl?: string }; + +type GitHubPullRequestSummary = { + number: number; + title: string; + url: string; + state: 'open' | 'closed' | 'merged'; + draft: boolean; + base: string; + head: string; + headSha?: string; + mergeable?: boolean | null; + mergeableState?: string | null; + author?: GitHubUserSummary | null; + body?: string; + createdAt?: string; + updatedAt?: string; + headLabel?: string; + headRepo?: GitHubPullRequestHeadRepo | null; +}; + +type GitHubIssueComment = { + id: number; + url: string; + body: string; + author?: GitHubUserSummary | null; + createdAt?: string; + updatedAt?: string; +}; + +type GitHubPullRequestReviewComment = { + id: number; + url: string; + body: string; + author?: GitHubUserSummary | null; + path?: string; + line?: number | null; + position?: number | null; + createdAt?: string; + updatedAt?: string; +}; + +type GitHubPullRequestFile = { + filename: string; + status?: string; + additions?: number; + deletions?: number; + changes?: number; + patch?: string; +}; + +export type GitHubPullRequestsListResult = { + connected: boolean; + repo?: GitHubRepoRef | null; + prs?: GitHubPullRequestSummary[]; + page?: number; + hasMore?: boolean; +}; + +export type GitHubPullRequestContextResult = { + connected: boolean; + repo?: GitHubRepoRef | null; + pr?: GitHubPullRequestSummary | null; + issueComments?: GitHubIssueComment[]; + reviewComments?: GitHubPullRequestReviewComment[]; + files?: GitHubPullRequestFile[]; + diff?: string; + checks?: GitHubChecksSummary | null; +}; + +const githubFetch = async ( + url: string, + accessToken: string, + init?: RequestInit, +): Promise => { + return fetch(url, { + ...init, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'OpenChamber', + ...(init?.headers || {}), + }, + }); +}; + +const githubFetchText = async ( + url: string, + accessToken: string, + accept: string, +): Promise => { + return fetch(url, { + headers: { + Accept: accept, + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'OpenChamber', + }, + }); +}; + +const jsonOrNull = async (response: Response): Promise => { + return (await response.json().catch(() => null)) as T | null; +}; + +const readString = (value: unknown): string => (typeof value === 'string' ? value : ''); + +const mapUser = (raw: unknown): GitHubUserSummary | null => { + const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null; + const login = readString(rec?.login); + if (!login) return null; + return { + login, + id: typeof rec?.id === 'number' ? rec.id : undefined, + avatarUrl: readString(rec?.avatar_url) || undefined, + name: undefined, + email: undefined, + }; +}; + +const mapHeadRepo = (raw: unknown): GitHubPullRequestHeadRepo | null => { + const rec = raw && typeof raw === 'object' ? (raw as JsonRecord) : null; + const ownerLogin = readString((rec?.owner as JsonRecord | undefined)?.login); + const repo = readString(rec?.name); + const url = readString(rec?.html_url); + if (!ownerLogin || !repo || !url) return null; + return { + owner: ownerLogin, + repo, + url, + cloneUrl: readString(rec?.clone_url) || undefined, + }; +}; + +const computeChecks = async (accessToken: string, repo: GitHubRepoRef, sha: string): Promise => { + const runsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/check-runs`, accessToken); + const runsJson = await jsonOrNull(runsResp); + const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs) + ? ((runsJson as JsonRecord).check_runs as unknown[]) + : []; + + if (runsResp.ok && runs.length > 0) { + const counts = { success: 0, failure: 0, pending: 0 }; + runs.forEach((r) => { + const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null; + const status = readString(rec?.status); + const conclusion = readString(rec?.conclusion); + if (status === 'queued' || status === 'in_progress') { + counts.pending += 1; + return; + } + if (!conclusion) { + counts.pending += 1; + return; + } + if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { + counts.success += 1; + } else { + counts.failure += 1; + } + }); + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 + ? 'failure' + : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + return { state, total, ...counts }; + } + + const statusResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${sha}/status`, accessToken); + const statusJson = await jsonOrNull(statusResp); + if (!statusResp.ok || !statusJson) return null; + const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : []; + const counts = { success: 0, failure: 0, pending: 0 }; + statuses.forEach((s) => { + const st = readString((s as JsonRecord | null)?.state); + if (st === 'success') counts.success += 1; + else if (st === 'failure' || st === 'error') counts.failure += 1; + else if (st === 'pending') counts.pending += 1; + }); + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 + ? 'failure' + : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + return { state, total, ...counts }; +}; + +export const listPullRequests = async ( + accessToken: string, + directory: string, + page: number = 1, +): Promise => { + const repo = await resolveRepoFromDirectory(directory); + if (!repo) { + return { connected: true, repo: null, prs: [] }; + } + + const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`); + url.searchParams.set('state', 'open'); + url.searchParams.set('per_page', '50'); + url.searchParams.set('page', String(page)); + + const resp = await githubFetch(url.toString(), accessToken); + if (resp.status === 401) return { connected: false }; + + const link = resp.headers.get('link') || ''; + const hasMore = /rel="next"/.test(link); + const json = await jsonOrNull(resp); + if (!resp.ok || !Array.isArray(json)) throw new Error('Failed to load PRs'); + + const prs = json.map((entry) => { + const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; + const number = typeof rec?.number === 'number' ? rec.number : 0; + const mergedAt = readString(rec?.merged_at); + const stateRaw = readString(rec?.state); + const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open'); + + const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null; + const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null; + + return { + number, + title: readString(rec?.title) || '', + url: readString(rec?.html_url) || '', + state, + draft: Boolean(rec?.draft), + base: readString(base?.ref) || '', + head: readString(head?.ref) || '', + headSha: readString(head?.sha) || undefined, + mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null, + mergeableState: readString(rec?.mergeable_state) || undefined, + author: mapUser(rec?.user), + headLabel: readString(head?.label) || undefined, + headRepo: mapHeadRepo(head?.repo), + } as GitHubPullRequestSummary; + }); + + return { connected: true, repo, prs, page, hasMore }; +}; + +export const getPullRequestContext = async ( + accessToken: string, + directory: string, + number: number, + includeDiff: boolean, +): Promise => { + const repo = await resolveRepoFromDirectory(directory); + if (!repo) { + return { connected: true, repo: null, pr: null }; + } + + const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken); + if (prResp.status === 401) return { connected: false }; + const prJson = await jsonOrNull(prResp); + if (!prResp.ok || !prJson) throw new Error('Failed to load PR'); + + const merged = Boolean(prJson.merged_at) || Boolean(prJson.merged); + const prState = readString(prJson.state); + const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open'); + const base = prJson.base && typeof prJson.base === 'object' ? (prJson.base as JsonRecord) : null; + const head = prJson.head && typeof prJson.head === 'object' ? (prJson.head as JsonRecord) : null; + + const pr: GitHubPullRequestSummary = { + number: typeof prJson.number === 'number' ? prJson.number : number, + title: readString(prJson.title) || '', + url: readString(prJson.html_url) || '', + state, + draft: Boolean(prJson.draft), + base: readString(base?.ref) || '', + head: readString(head?.ref) || '', + headSha: readString(head?.sha) || undefined, + mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null, + mergeableState: readString(prJson.mergeable_state) || undefined, + author: mapUser(prJson.user), + headLabel: readString(head?.label) || undefined, + headRepo: mapHeadRepo(head?.repo), + body: readString(prJson.body) || '', + createdAt: readString(prJson.created_at) || undefined, + updatedAt: readString(prJson.updated_at) || undefined, + }; + + const issueCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues/${number}/comments?per_page=100`, accessToken); + if (issueCommentsResp.status === 401) return { connected: false }; + const issueCommentsJson = await jsonOrNull(issueCommentsResp); + if (!issueCommentsResp.ok || !Array.isArray(issueCommentsJson)) throw new Error('Failed to load PR issue comments'); + const issueComments: GitHubIssueComment[] = issueCommentsJson + .map((entry) => { + const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; + const id = typeof rec?.id === 'number' ? rec.id : 0; + if (!id) return null; + return { + id, + url: readString(rec?.html_url) || '', + body: readString(rec?.body) || '', + author: mapUser(rec?.user), + createdAt: readString(rec?.created_at) || undefined, + updatedAt: readString(rec?.updated_at) || undefined, + }; + }) + .filter(Boolean) as GitHubIssueComment[]; + + const reviewCommentsResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/comments?per_page=100`, accessToken); + if (reviewCommentsResp.status === 401) return { connected: false }; + const reviewCommentsJson = await jsonOrNull(reviewCommentsResp); + if (!reviewCommentsResp.ok || !Array.isArray(reviewCommentsJson)) throw new Error('Failed to load PR review comments'); + const reviewComments: GitHubPullRequestReviewComment[] = reviewCommentsJson + .map((entry) => { + const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; + const id = typeof rec?.id === 'number' ? rec.id : 0; + if (!id) return null; + return { + id, + url: readString(rec?.html_url) || '', + body: readString(rec?.body) || '', + author: mapUser(rec?.user), + path: readString(rec?.path) || undefined, + line: typeof rec?.line === 'number' ? rec.line : null, + position: typeof rec?.position === 'number' ? rec.position : null, + createdAt: readString(rec?.created_at) || undefined, + updatedAt: readString(rec?.updated_at) || undefined, + }; + }) + .filter(Boolean) as GitHubPullRequestReviewComment[]; + + const filesResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}/files?per_page=100`, accessToken); + if (filesResp.status === 401) return { connected: false }; + const filesJson = await jsonOrNull(filesResp); + if (!filesResp.ok || !Array.isArray(filesJson)) throw new Error('Failed to load PR files'); + const files: GitHubPullRequestFile[] = filesJson + .map((entry) => { + const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null; + const filename = readString(rec?.filename); + if (!filename) return null; + return { + filename, + status: readString(rec?.status) || undefined, + additions: typeof rec?.additions === 'number' ? rec.additions : undefined, + deletions: typeof rec?.deletions === 'number' ? rec.deletions : undefined, + changes: typeof rec?.changes === 'number' ? rec.changes : undefined, + patch: readString(rec?.patch) || undefined, + }; + }) + .filter(Boolean) as GitHubPullRequestFile[]; + + const checks = pr.headSha ? await computeChecks(accessToken, repo, pr.headSha) : null; + + let diff: string | undefined; + if (includeDiff) { + const diffResp = await githubFetchText(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken, 'application/vnd.github.v3.diff'); + if (diffResp.status === 401) return { connected: false }; + if (diffResp.ok) { + diff = await diffResp.text().catch(() => undefined); + } + } + + return { connected: true, repo, pr, issueComments, reviewComments, files, diff, checks }; +}; diff --git a/packages/vscode/webview/api/github.ts b/packages/vscode/webview/api/github.ts index 9771c58d..371566b2 100644 --- a/packages/vscode/webview/api/github.ts +++ b/packages/vscode/webview/api/github.ts @@ -4,6 +4,8 @@ import type { GitHubIssueCommentsResult, GitHubIssueGetResult, GitHubIssuesListResult, + GitHubPullRequestContextResult, + GitHubPullRequestsListResult, GitHubPullRequest, GitHubPullRequestCreateInput, GitHubPullRequestMergeInput, @@ -35,10 +37,15 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({ prReady: async (payload: GitHubPullRequestReadyInput) => sendBridgeMessage('api:github/pr:ready', payload), - issuesList: async (directory: string) => - sendBridgeMessage('api:github/issues:list', { directory }), + issuesList: async (directory: string, options?: { page?: number }) => + sendBridgeMessage('api:github/issues:list', { directory, page: options?.page ?? 1 }), issueGet: async (directory: string, number: number) => sendBridgeMessage('api:github/issues:get', { directory, number }), issueComments: async (directory: string, number: number) => sendBridgeMessage('api:github/issues:comments', { directory, number }), + + prsList: async (directory: string, options?: { page?: number }) => + sendBridgeMessage('api:github/pulls:list', { directory, page: options?.page ?? 1 }), + prContext: async (directory: string, number: number, options?: { includeDiff?: boolean }) => + sendBridgeMessage('api:github/pulls:context', { directory, number, includeDiff: Boolean(options?.includeDiff) }), }); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 2c8d2807..64a2a3ed 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -4365,6 +4365,7 @@ async function main(options = {}) { app.get('/api/github/issues/list', async (req, res) => { try { const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; if (!directory) { return res.status(400).json({ error: 'directory is required' }); } @@ -4386,7 +4387,10 @@ async function main(options = {}) { repo: repo.repo, state: 'open', per_page: 50, + page: Number.isFinite(page) && page > 0 ? page : 1, }); + const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; + const hasMore = /rel="next"/.test(link); const issues = (Array.isArray(list?.data) ? list.data : []) .filter((item) => !item?.pull_request) .map((item) => ({ @@ -4407,7 +4411,7 @@ async function main(options = {}) { : [], })); - return res.json({ connected: true, repo, issues }); + return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); } catch (error) { console.error('Failed to list GitHub issues:', error); return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' }); @@ -4518,6 +4522,268 @@ async function main(options = {}) { } }); + // ================= GitHub Pull Request Context APIs ================= + + app.get('/api/github/pulls/list', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; + if (!directory) { + return res.status(400).json({ error: 'directory is required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, prs: [] }); + } + + const list = await octokit.rest.pulls.list({ + owner: repo.owner, + repo: repo.repo, + state: 'open', + per_page: 50, + page: Number.isFinite(page) && page > 0 ? page : 1, + }); + + const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; + const hasMore = /rel="next"/.test(link); + + const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => { + const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'); + const headRepo = pr.head?.repo + ? { + owner: pr.head.repo.owner?.login, + repo: pr.head.repo.name, + url: pr.head.repo.html_url, + cloneUrl: pr.head.repo.clone_url, + } + : null; + return { + number: pr.number, + title: pr.title, + url: pr.html_url, + state: mergedState, + draft: Boolean(pr.draft), + base: pr.base?.ref, + head: pr.head?.ref, + headSha: pr.head?.sha, + mergeable: pr.mergeable, + mergeableState: pr.mergeable_state, + author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null, + headLabel: pr.head?.label, + headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url + ? headRepo + : null, + }; + }); + + return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); + } catch (error) { + if (error?.status === 401) { + const { clearGitHubAuth } = await getGitHubLibraries(); + clearGitHubAuth(); + return res.json({ connected: false }); + } + console.error('Failed to list GitHub PRs:', error); + return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' }); + } + }); + + app.get('/api/github/pulls/context', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null; + const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true'; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false }); + } + + const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo) { + return res.json({ connected: true, repo: null, pr: null }); + } + + const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number }); + const prData = prResp?.data; + if (!prData) { + return res.status(404).json({ error: 'PR not found' }); + } + + const headRepo = prData.head?.repo + ? { + owner: prData.head.repo.owner?.login, + repo: prData.head.repo.name, + url: prData.head.repo.html_url, + cloneUrl: prData.head.repo.clone_url, + } + : null; + + const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open'); + const pr = { + number: prData.number, + title: prData.title, + url: prData.html_url, + state: mergedState, + draft: Boolean(prData.draft), + base: prData.base?.ref, + head: prData.head?.ref, + headSha: prData.head?.sha, + mergeable: prData.mergeable, + mergeableState: prData.mergeable_state, + author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null, + headLabel: prData.head?.label, + headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null, + body: prData.body || '', + createdAt: prData.created_at, + updatedAt: prData.updated_at, + }; + + const issueCommentsResp = await octokit.rest.issues.listComments({ + owner: repo.owner, + repo: repo.repo, + issue_number: number, + per_page: 100, + }); + const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({ + id: comment.id, + url: comment.html_url, + body: comment.body || '', + createdAt: comment.created_at, + updatedAt: comment.updated_at, + author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, + })); + + const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + per_page: 100, + }); + const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({ + id: comment.id, + url: comment.html_url, + body: comment.body || '', + createdAt: comment.created_at, + updatedAt: comment.updated_at, + path: comment.path, + line: typeof comment.line === 'number' ? comment.line : null, + position: typeof comment.position === 'number' ? comment.position : null, + author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null, + })); + + const filesResp = await octokit.rest.pulls.listFiles({ + owner: repo.owner, + repo: repo.repo, + pull_number: number, + per_page: 100, + }); + const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + changes: f.changes, + patch: f.patch, + })); + + // checks summary (same logic as status endpoint) + let checks = null; + const sha = prData.head?.sha; + if (sha) { + try { + const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 }); + const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : []; + if (checkRuns.length > 0) { + const counts = { success: 0, failure: 0, pending: 0 }; + for (const run of checkRuns) { + const status = run?.status; + const conclusion = run?.conclusion; + if (status === 'queued' || status === 'in_progress') { + counts.pending += 1; + continue; + } + if (!conclusion) { + counts.pending += 1; + continue; + } + if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { + counts.success += 1; + } else { + counts.failure += 1; + } + } + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } + } catch { + // ignore and fall back + } + if (!checks) { + try { + const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha }); + const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; + const counts = { success: 0, failure: 0, pending: 0 }; + statuses.forEach((s) => { + if (s.state === 'success') counts.success += 1; + else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; + else if (s.state === 'pending') counts.pending += 1; + }); + const total = counts.success + counts.failure + counts.pending; + const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); + checks = { state, total, ...counts }; + } catch { + checks = null; + } + } + } + + let diff = undefined; + if (includeDiff) { + const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner: repo.owner, + repo: repo.repo, + pull_number: number, + headers: { accept: 'application/vnd.github.v3.diff' }, + }); + diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined; + } + + return res.json({ + connected: true, + repo, + pr, + issueComments, + reviewComments, + files, + ...(diff ? { diff } : {}), + checks, + }); + } catch (error) { + if (error?.status === 401) { + const { clearGitHubAuth } = await getGitHubLibraries(); + clearGitHubAuth(); + return res.json({ connected: false }); + } + console.error('Failed to load GitHub PR context:', error); + return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' }); + } + }); + app.get('/api/provider/:providerId/source', async (req, res) => { try { const { providerId } = req.params; diff --git a/packages/web/src/api/github.ts b/packages/web/src/api/github.ts index e163f47e..bc64e2ce 100644 --- a/packages/web/src/api/github.ts +++ b/packages/web/src/api/github.ts @@ -4,6 +4,8 @@ import type { GitHubIssueCommentsResult, GitHubIssueGetResult, GitHubIssuesListResult, + GitHubPullRequestContextResult, + GitHubPullRequestsListResult, GitHubPullRequest, GitHubPullRequestCreateInput, GitHubPullRequestMergeInput, @@ -125,9 +127,38 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ return body; }, - async issuesList(directory: string): Promise { + async prsList(directory: string, options?: { page?: number }): Promise { + const page = options?.page ?? 1; const response = await fetch( - `/api/github/issues/list?directory=${encodeURIComponent(directory)}`, + `/api/github/pulls/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`, + { method: 'GET', headers: { Accept: 'application/json' } } + ); + const body = await jsonOrNull(response); + if (!response.ok || !body) { + throw new Error(body?.error || response.statusText || 'Failed to load pull requests'); + } + return body; + }, + + async prContext(directory: string, number: number, options?: { includeDiff?: boolean }): Promise { + const url = new URL('/api/github/pulls/context', window.location.origin); + url.searchParams.set('directory', directory); + url.searchParams.set('number', String(number)); + if (options?.includeDiff) { + url.searchParams.set('diff', '1'); + } + const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } }); + const body = await jsonOrNull(response); + if (!response.ok || !body) { + throw new Error(body?.error || response.statusText || 'Failed to load pull request context'); + } + return body; + }, + + async issuesList(directory: string, options?: { page?: number }): Promise { + const page = options?.page ?? 1; + const response = await fetch( + `/api/github/issues/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`, { method: 'GET', headers: { Accept: 'application/json' } } ); const payload = await jsonOrNull(response);