Add GitHub integration for PRs, issues and AI PR description (#205)

* feat: integrate GitHub OAuth device flow across runtimes

Add GitHub OAuth device flow endpoints across runtimes
Introduce GitHubSettings UI panel and sidebar entry
Persist GitHub auth state in per-runtime storage

* feat: add GitHub PR status and PR description generation

Show PR status for the current branch in the Git view
Generate a pull request description from the diff between base and head
Expose prStatus, prCreate, and prMerge APIs in web and desktop clients

* feat: add GitHub PR ready for review

Add API to mark pull requests as ready for review
Show a Ready button for draft PRs and reflect status in UI
Handle token expiration and GraphQL errors when marking ready
This commit is contained in:
Bohdan Triapitsyn
2026-01-23 16:08:58 +02:00
committed by GitHub
parent 0e715be7d6
commit 463e9ec4e3
43 changed files with 4999 additions and 106 deletions
+1 -1
View File
@@ -2977,7 +2977,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.5.3"
version = "1.5.5"
dependencies = [
"anyhow",
"axum",
@@ -267,7 +267,6 @@ async fn handle_question_asked(
properties: &Value,
notified_questions: &Mutex<HashSet<String>>,
) {
let session_id = properties.get("sessionID").and_then(Value::as_str);
let question_id = properties.get("id").and_then(Value::as_str);
@@ -234,13 +234,11 @@ pub async fn list_directory(
.output();
match output {
Ok(out) => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
Ok(out) => String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
Err(_) => HashSet::new(),
}
})
@@ -507,9 +505,13 @@ pub async fn delete_path(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_delete_message())?;
let resolved_path = resolve_sandboxed_path(
Some(trimmed.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|err| err.to_delete_message())?;
let metadata = fs::metadata(&resolved_path)
.await
@@ -544,9 +546,13 @@ pub async fn rename_path(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_old = resolve_sandboxed_path(Some(trimmed_old.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_rename_message())?;
let resolved_old = resolve_sandboxed_path(
Some(trimmed_old.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|err| err.to_rename_message())?;
let resolved_new = resolve_creatable_path(trimmed_new, &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_rename_message())?;
@@ -895,9 +901,13 @@ pub async fn read_file(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "File not found or access denied".to_string())?;
let resolved_path = resolve_sandboxed_path(
Some(trimmed.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|_| "File not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_path)
.await
@@ -932,9 +942,13 @@ pub async fn read_file_binary(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "File not found or access denied".to_string())?;
let resolved_path = resolve_sandboxed_path(
Some(trimmed.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|_| "File not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_path)
.await
@@ -1041,14 +1055,8 @@ fn build_shell_path_command(shell: &str) -> Vec<String> {
"-lic".to_string(),
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(),
],
"fish" => vec![
"-lic".to_string(),
"echo \"__PATH__=$PATH\"".to_string(),
],
_ => vec![
"-lic".to_string(),
"echo \"__PATH__=$PATH\"".to_string(),
],
"fish" => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()],
_ => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()],
}
}
@@ -1130,9 +1138,13 @@ pub async fn exec_commands(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_cwd = resolve_sandboxed_path(Some(cwd_trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "Working directory not found or access denied".to_string())?;
let resolved_cwd = resolve_sandboxed_path(
Some(cwd_trimmed.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|_| "Working directory not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_cwd)
.await
+139 -2
View File
@@ -2042,7 +2042,9 @@ pub async fn get_remote_url(
.map_err(|e| e.to_string())?;
let remote_name = remote.unwrap_or_else(|| "origin".to_string());
let url = run_git(&["remote", "get-url", &remote_name], &root).await.ok();
let url = run_git(&["remote", "get-url", &remote_name], &root)
.await
.ok();
Ok(url.filter(|s| !s.is_empty()))
}
@@ -2162,7 +2164,11 @@ pub async fn set_git_identity(
.await
.map_err(|e| e.to_string())?;
}
let _ = run_git(&["config", "--local", "--unset", "credential.helper"], &root).await;
let _ = run_git(
&["config", "--local", "--unset", "credential.helper"],
&root,
)
.await;
} else if auth_type == "token" && profile.host.is_some() {
run_git(&["config", "--local", "credential.helper", "store"], &root)
.await
@@ -2330,3 +2336,134 @@ Diff summary:
last_error.unwrap_or_else(|| "unknown error".to_string())
))
}
#[tauri::command]
pub async fn generate_pr_description(
directory: String,
base: String,
head: String,
state: State<'_, DesktopRuntime>,
) -> Result<serde_json::Value, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
if base.trim().is_empty() || head.trim().is_empty() {
return Err("base and head are required".to_string());
}
// 1. Collect PR range diffs (base...head)
let range = format!("{}...{}", base.trim(), head.trim());
let files = {
let args = vec!["diff", "--name-only", range.as_str()];
let raw = run_git(&args, &root).await.unwrap_or_default();
raw.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect::<Vec<String>>()
};
if files.is_empty() {
return Err("No diffs available for base...head".to_string());
}
let mut diff_summaries = String::new();
for file in files.iter() {
let context = "-U3";
let args = vec![
"diff",
"--no-color",
context,
range.as_str(),
"--",
file.as_str(),
];
if let Ok(diff) = run_git(&args, &root).await {
if !diff.trim().is_empty() {
diff_summaries.push_str(&format!("FILE: {}\n{}\n\n", file, diff));
}
}
}
if diff_summaries.is_empty() {
return Err("No diffs available for selected files".to_string());
}
// 2. Construct PR-specific prompt
let prompt = format!(
r#"You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {{\"title\": string, \"body\": string}} (ONLY JSON in response, no markdown fences) with these rules:
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no \"feat:\", \"fix:\")
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
- Testing: bullet list (\"- Not tested\" allowed)
- Notes: bullet list; include breaking/rollout notes only when relevant
Context:
- base branch: {base}
- head branch: {head}
Diff summary:
{diffs}"#,
base = base.trim(),
head = head.trim(),
diffs = diff_summaries
);
let model = "gpt-5-nano";
// 3. Call API
let client = Client::new();
let res = client
.post("https://opencode.ai/zen/v1/responses")
.json(&serde_json::json!({
"model": model,
"input": [{ "role": "user", "content": prompt }],
"max_output_tokens": 1200,
"stream": false,
"reasoning": { "effort": "low" }
}))
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("API request failed: {}", res.status()));
}
let body_json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
let raw_content = body_json["output"]
.as_array()
.and_then(|items| items.iter().find(|item| item["type"] == "message"))
.and_then(|item| item["content"].as_array())
.and_then(|content| content.iter().find(|entry| entry["type"] == "output_text"))
.and_then(|entry| entry["text"].as_str())
.unwrap_or("")
.trim();
if raw_content.is_empty() {
return Err("No PR description returned by generator".to_string());
}
let cleaned = raw_content
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let extracted = extract_json_object(cleaned);
let candidates = [
Some(cleaned.to_string()),
extracted,
Some(raw_content.to_string()),
];
for candidate in candidates.iter().flatten() {
if !(candidate.starts_with('{') || candidate.starts_with('[')) {
continue;
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(candidate) {
let title = parsed.get("title").and_then(|v| v.as_str()).unwrap_or("");
let body = parsed.get("body").and_then(|v| v.as_str()).unwrap_or("");
return Ok(serde_json::json!({ "title": title, "body": body }));
}
}
Ok(serde_json::json!({ "title": "", "body": raw_content }))
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
pub mod files;
pub mod git;
pub mod github;
pub mod logs;
pub mod notifications;
pub mod permissions;
@@ -129,7 +129,10 @@ pub async fn process_directory_selection(
if let Some(obj) = settings.as_object_mut() {
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
obj.insert("lastDirectory".to_string(), json!(normalized_path_for_update));
obj.insert(
"lastDirectory".to_string(),
json!(normalized_path_for_update),
);
}
(settings, project_id)
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashSet;
use tauri::State;
@@ -233,6 +233,20 @@ fn sanitize_settings_update(payload: &Value) -> Value {
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
}
}
// GitHub OAuth config (non-secret)
if let Some(Value::String(s)) = obj.get("githubClientId") {
let trimmed = s.trim();
if !trimmed.is_empty() {
result_obj.insert("githubClientId".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("githubScopes") {
let trimmed = s.trim();
if !trimmed.is_empty() {
result_obj.insert("githubScopes".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("defaultModel") {
let trimmed = s.trim();
if trimmed.is_empty() {
@@ -304,7 +318,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(10).min(500);
@@ -314,7 +331,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(20).min(500);
@@ -324,7 +344,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(30).min(1000);
+55 -14
View File
@@ -28,18 +28,26 @@ use axum::{
routing::{any, get, post},
Json, Router,
};
use commands::files::{create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary, rename_path, search_files, write_file};
use commands::files::{
create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary,
rename_path, search_files, write_file,
};
use commands::git::{
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch,
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit,
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
get_current_git_identity, has_local_identity, get_git_branches, get_git_diff, get_git_file_diff,
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message,
get_commit_files, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url,
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
revert_git_file, set_git_identity, update_git_identity,
git_fetch, git_pull, git_push, has_local_identity, is_linked_worktree, list_git_worktrees,
remove_git_worktree, rename_branch, revert_git_file, set_git_identity, update_git_identity,
generate_pr_description,
};
use commands::logs::fetch_desktop_logs;
use commands::github::{
github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
github_pr_create, github_pr_merge, github_pr_ready, github_pr_status,
};
use commands::notifications::desktop_notify;
use commands::permissions::{
pick_directory, process_directory_selection, request_directory_access,
@@ -880,6 +888,7 @@ fn main() {
set_git_identity,
discover_git_credentials,
generate_commit_message,
generate_pr_description,
create_terminal_session,
send_terminal_input,
resize_terminal,
@@ -888,6 +897,15 @@ fn main() {
force_kill_terminal,
fetch_desktop_logs,
desktop_notify,
github_auth_status,
github_auth_start,
github_auth_complete,
github_auth_disconnect,
github_me,
github_pr_status,
github_pr_create,
github_pr_merge,
github_pr_ready,
])
.on_menu_event(|app, event| {
#[cfg(target_os = "macos")]
@@ -2256,7 +2274,9 @@ async fn handle_config_routes(
resolve_project_directory(&state, None).await.ok()
};
match opencode_config::get_provider_sources(trimmed, working_directory.as_deref()).await {
match opencode_config::get_provider_sources(trimmed, working_directory.as_deref())
.await
{
Ok(mut sources) => {
let auth = opencode_auth::get_provider_auth(trimmed).await;
sources.auth.exists = auth.ok().flatten().is_some();
@@ -2324,13 +2344,30 @@ async fn handle_config_routes(
let removal_result = if scope == "auth" {
opencode_auth::remove_provider_auth(trimmed).await
} else if scope == "user" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::User).await
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::User,
)
.await
} else if scope == "project" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Project).await
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::Project,
)
.await
} else if scope == "custom" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Custom).await
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::Custom,
)
.await
} else if scope == "all" {
let auth_removed = opencode_auth::remove_provider_auth(trimmed).await.unwrap_or(false);
let auth_removed = opencode_auth::remove_provider_auth(trimmed)
.await
.unwrap_or(false);
let user_removed = opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
@@ -2500,7 +2537,10 @@ async fn change_directory_handler(
"activeProjectId".to_string(),
Value::String(active_project_id.clone()),
);
map.insert("lastDirectory".to_string(), Value::String(path_value.clone()));
map.insert(
"lastDirectory".to_string(),
Value::String(path_value.clone()),
);
settings
})
@@ -2645,7 +2685,6 @@ impl SettingsStore {
}
}
pub(crate) async fn update_with<R, F>(&self, f: F) -> Result<(Value, R)>
where
F: FnOnce(Value) -> (Value, R),
@@ -2653,7 +2692,9 @@ impl SettingsStore {
let _lock = self.guard.lock().await;
let current = match fs::read(&self.path).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())),
Ok(bytes) => {
serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default()))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Value::Object(Default::default())
}
@@ -143,7 +143,10 @@ fn get_project_config_file(working_directory: &Path) -> PathBuf {
}
// Default to root opencode.json for new configs
candidates.into_iter().next().unwrap_or_else(|| working_directory.join("opencode.json"))
candidates
.into_iter()
.next()
.unwrap_or_else(|| working_directory.join("opencode.json"))
}
/// Get custom config file path from OPENCODE_CONFIG env var
@@ -390,15 +393,29 @@ pub async fn get_provider_sources(
.is_some();
Ok(ProviderSources {
auth: ProviderSourceInfo { exists: false, path: None },
user: ProviderSourceInfo { exists: user_exists, path: Some(layers.paths.user.to_string_lossy().to_string()) },
auth: ProviderSourceInfo {
exists: false,
path: None,
},
user: ProviderSourceInfo {
exists: user_exists,
path: Some(layers.paths.user.to_string_lossy().to_string()),
},
project: ProviderSourceInfo {
exists: project_exists,
path: layers.paths.project.as_ref().map(|p| p.to_string_lossy().to_string()),
path: layers
.paths
.project
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
},
custom: ProviderSourceInfo {
exists: custom_exists,
path: layers.paths.custom.as_ref().map(|p| p.to_string_lossy().to_string()),
path: layers
.paths
.custom
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
},
})
}
@@ -432,10 +449,7 @@ pub async fn remove_provider_config(
let mut remove_provider_key = false;
let mut remove_providers_key = false;
if let Some(provider_section) = config
.get_mut("provider")
.and_then(|v| v.as_object_mut())
{
if let Some(provider_section) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
if provider_section.remove(provider_id).is_some() {
removed = true;
if provider_section.is_empty() {
@@ -444,10 +458,7 @@ pub async fn remove_provider_config(
}
}
if let Some(provider_section) = config
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
if let Some(provider_section) = config.get_mut("providers").and_then(|v| v.as_object_mut()) {
if provider_section.remove(provider_id).is_some() {
removed = true;
if provider_section.is_empty() {
@@ -485,7 +496,8 @@ fn get_legacy_project_agent_dir(working_directory: &Path) -> PathBuf {
/// Get project-level agent path
fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf {
let plural_path = get_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
let legacy_path = get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
let legacy_path =
get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
if legacy_path.exists() && !plural_path.exists() {
return legacy_path;
}
@@ -566,8 +578,10 @@ fn get_legacy_project_command_dir(working_directory: &Path) -> PathBuf {
/// Get project-level command path
fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf {
let plural_path = get_project_command_dir(working_directory).join(format!("{}.md", command_name));
let legacy_path = get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name));
let plural_path =
get_project_command_dir(working_directory).join(format!("{}.md", command_name));
let legacy_path =
get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name));
if legacy_path.exists() && !plural_path.exists() {
return legacy_path;
}
@@ -791,7 +791,12 @@ async fn scan_clawdhub() -> Result<Vec<SkillsCatalogItem>> {
for _ in 0..max_pages {
let url = match &cursor {
Some(c) => format!("{}{}?cursor={}", CLAWDHUB_API_BASE, "/skills", urlencoding::encode(c)),
Some(c) => format!(
"{}{}?cursor={}",
CLAWDHUB_API_BASE,
"/skills",
urlencoding::encode(c)
),
None => format!("{}/skills", CLAWDHUB_API_BASE),
};
@@ -1440,7 +1445,11 @@ async fn install_skills_from_clawdhub(
// Check for conflicts first
let mut conflicts = vec![];
for sel in &req.selections {
let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir);
let slug = sel
.clawdhub
.as_ref()
.map(|c| c.slug.as_str())
.unwrap_or(&sel.skill_dir);
if !validate_skill_name(slug) {
continue;
}
@@ -1478,8 +1487,17 @@ async fn install_skills_from_clawdhub(
}
for sel in &req.selections {
let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir);
let mut version = sel.clawdhub.as_ref().map(|c| c.version.as_str()).unwrap_or("latest").to_string();
let slug = sel
.clawdhub
.as_ref()
.map(|c| c.slug.as_str())
.unwrap_or(&sel.skill_dir);
let mut version = sel
.clawdhub
.as_ref()
.map(|c| c.version.as_str())
.unwrap_or("latest")
.to_string();
if !validate_skill_name(slug) {
skipped.push(SkippedSkill {
@@ -1548,7 +1566,8 @@ async fn install_skills_from_clawdhub(
// Download and extract
match download_clawdhub_skill(slug, &version).await {
Ok(zip_data) => {
let temp_dir = std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4()));
let temp_dir =
std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4()));
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
// Extract ZIP using the zip crate
+12
View File
@@ -10,6 +10,7 @@ import type {
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GeneratedCommitMessage,
GeneratedPullRequestDescription,
GitWorktreeInfo,
GitAddWorktreePayload,
GitRemoveWorktreePayload,
@@ -108,6 +109,17 @@ export const createDesktopGitAPI = (): GitAPI => ({
return response;
},
async generatePullRequestDescription(
directory: string,
payload: { base: string; head: string }
): Promise<GeneratedPullRequestDescription> {
return safeGitInvoke<GeneratedPullRequestDescription>('generate_pr_description', {
directory,
base: payload.base,
head: payload.head,
});
},
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
return safeGitInvoke<GitWorktreeInfo[]>('list_git_worktrees', { directory });
},
+62
View File
@@ -0,0 +1,62 @@
import type {
GitHubAPI,
GitHubAuthStatus,
GitHubPullRequest,
GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput,
GitHubPullRequestMergeResult,
GitHubPullRequestReadyInput,
GitHubPullRequestReadyResult,
GitHubPullRequestStatus,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubUserSummary,
} from '@openchamber/ui/lib/api/types';
export const createDesktopGitHubAPI = (): GitHubAPI => ({
async authStatus(): Promise<GitHubAuthStatus> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubAuthStatus>('github_auth_status', {}, { timeout: 8000 });
},
async authStart(): Promise<GitHubDeviceFlowStart> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubDeviceFlowStart>('github_auth_start', {}, { timeout: 8000 });
},
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubDeviceFlowComplete>('github_auth_complete', { deviceCode }, { timeout: 12000 });
},
async authDisconnect(): Promise<{ removed: boolean }> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<{ removed: boolean }>('github_auth_disconnect', {}, { timeout: 8000 });
return { removed: Boolean(result?.removed) };
},
async me(): Promise<GitHubUserSummary> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubUserSummary>('github_me', {}, { timeout: 8000 });
},
async prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestStatus>('github_pr_status', { directory, branch }, { timeout: 12000 });
},
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequest>('github_pr_create', payload, { timeout: 20000 });
},
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestMergeResult>('github_pr_merge', payload, { timeout: 20000 });
},
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
},
});
+2
View File
@@ -7,6 +7,7 @@ import { createDesktopPermissionsAPI } from './permissions';
import { createDesktopDiagnosticsAPI } from './diagnostics';
import { createDesktopNotificationsAPI } from './notifications';
import { createDesktopToolsAPI } from './tools';
import { createDesktopGitHubAPI } from './github';
const activeTerminalConnections = new Set<string>();
@@ -39,6 +40,7 @@ export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => {
settings: createDesktopSettingsAPI(),
permissions: createDesktopPermissionsAPI(),
notifications: createDesktopNotificationsAPI(),
github: createDesktopGitHubAPI(),
diagnostics: createDesktopDiagnosticsAPI(),
tools: createDesktopToolsAPI(),
cleanup: () => {
@@ -0,0 +1,332 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { RiGithubFill } from '@remixicon/react';
type GitHubUser = {
login: string;
id?: number;
avatarUrl?: string;
name?: string;
email?: string;
};
type AuthStatusResponse = {
connected: boolean;
user?: GitHubUser | null;
scope?: string;
error?: string;
};
type DeviceFlowStartResponse = {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
expiresIn: number;
interval: number;
scope?: string;
};
type DeviceFlowCompleteResponse =
| { connected: true; user: GitHubUser; scope?: string }
| { connected: false; status?: string; error?: string };
export const GitHubSettings: React.FC = () => {
const runtimeGitHub = getRegisteredRuntimeAPIs()?.github;
const openExternal = React.useCallback(async (url: string) => {
if (typeof window === 'undefined') {
return;
}
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
if (desktop?.openExternal) {
try {
await desktop.openExternal(url);
return;
} catch {
// fall through
}
}
try {
window.open(url, '_blank', 'noopener,noreferrer');
} catch {
// ignore
}
}, []);
const [isLoading, setIsLoading] = React.useState(true);
const [isBusy, setIsBusy] = React.useState(false);
const [status, setStatus] = React.useState<AuthStatusResponse | null>(null);
const [flow, setFlow] = React.useState<DeviceFlowStartResponse | null>(null);
const [pollIntervalMs, setPollIntervalMs] = React.useState<number | null>(null);
const pollTimerRef = React.useRef<number | null>(null);
const stopPolling = React.useCallback(() => {
if (pollTimerRef.current != null) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
setPollIntervalMs(null);
}, []);
const refreshStatus = React.useCallback(async () => {
if (runtimeGitHub) {
const payload = await runtimeGitHub.authStatus();
setStatus(payload as AuthStatusResponse);
return payload as AuthStatusResponse;
}
const response = await fetch('/api/github/auth/status', {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as AuthStatusResponse | null;
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status');
}
setStatus(payload);
return payload;
}, [runtimeGitHub]);
React.useEffect(() => {
let mounted = true;
(async () => {
try {
await refreshStatus();
} catch (error) {
console.warn('Failed to load GitHub auth status:', error);
} finally {
if (mounted) setIsLoading(false);
}
})();
return () => {
mounted = false;
stopPolling();
};
}, [refreshStatus, stopPolling]);
const startConnect = React.useCallback(async () => {
setIsBusy(true);
try {
const payload = runtimeGitHub
? await runtimeGitHub.authStart()
: await (async () => {
const response = await fetch('/api/github/auth/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({}),
});
const body = (await response.json().catch(() => null)) as DeviceFlowStartResponse | { error?: string } | null;
if (!response.ok || !body || !('deviceCode' in body)) {
throw new Error((body as { error?: string } | null)?.error || response.statusText);
}
return body;
})();
setFlow(payload);
setPollIntervalMs(Math.max(1, payload.interval) * 1000);
const url = payload.verificationUriComplete || payload.verificationUri;
void openExternal(url);
} catch (error) {
console.error('Failed to start GitHub connect:', error);
toast.error('Failed to start GitHub connect');
} finally {
setIsBusy(false);
}
}, [openExternal, runtimeGitHub]);
const pollOnce = React.useCallback(async (deviceCode: string) => {
if (runtimeGitHub) {
return runtimeGitHub.authComplete(deviceCode) as Promise<DeviceFlowCompleteResponse>;
}
const response = await fetch('/api/github/auth/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ deviceCode }),
});
const payload = (await response.json().catch(() => null)) as DeviceFlowCompleteResponse | { error?: string } | null;
if (!response.ok || !payload) {
throw new Error((payload as { error?: string } | null)?.error || response.statusText);
}
return payload as DeviceFlowCompleteResponse;
}, [runtimeGitHub]);
React.useEffect(() => {
if (!flow?.deviceCode || !pollIntervalMs) {
return;
}
if (pollTimerRef.current != null) {
return;
}
pollTimerRef.current = window.setInterval(() => {
void (async () => {
try {
const result = await pollOnce(flow.deviceCode);
if (result.connected) {
toast.success('GitHub connected');
setFlow(null);
stopPolling();
await refreshStatus();
return;
}
if (result.status === 'slow_down') {
setPollIntervalMs((prev) => (prev ? prev + 5000 : 5000));
}
if (result.status === 'expired_token' || result.status === 'access_denied') {
toast.error(result.error || 'GitHub authorization failed');
setFlow(null);
stopPolling();
}
} catch (error) {
console.warn('GitHub polling failed:', error);
}
})();
}, pollIntervalMs);
return () => {
if (pollTimerRef.current != null) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
};
}, [flow, pollIntervalMs, pollOnce, refreshStatus, stopPolling]);
const disconnect = React.useCallback(async () => {
setIsBusy(true);
try {
stopPolling();
setFlow(null);
if (runtimeGitHub) {
await runtimeGitHub.authDisconnect();
} else {
const response = await fetch('/api/github/auth', {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(response.statusText);
}
}
toast.success('GitHub disconnected');
await refreshStatus();
} catch (error) {
console.error('Failed to disconnect GitHub:', error);
toast.error('Failed to disconnect GitHub');
} finally {
setIsBusy(false);
}
}, [refreshStatus, stopPolling, runtimeGitHub]);
if (isLoading) {
return null;
}
const connected = Boolean(status?.connected);
const user = status?.user;
return (
<div className="space-y-6">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">GitHub</h3>
<p className="typography-meta text-muted-foreground">
Connect a GitHub account for in-app PR and issue workflows.
</p>
</div>
{connected ? (
<div className="flex items-center justify-between gap-4 rounded-lg border bg-background/50 px-4 py-3">
<div className="flex min-w-0 items-center gap-4">
{user?.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.login ? `${user.login} avatar` : 'GitHub avatar'}
className="h-14 w-14 shrink-0 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="h-14 w-14 shrink-0 rounded-full border border-border/60 bg-muted" />
)}
<div className="min-w-0">
<div className="typography-ui-header font-semibold text-foreground truncate">
{user?.name?.trim() || user?.login || 'GitHub'}
</div>
{user?.email ? (
<div className="typography-body text-muted-foreground truncate">{user.email}</div>
) : null}
<div className="mt-1 flex items-center gap-2 typography-meta text-muted-foreground truncate">
<RiGithubFill className="h-4 w-4" />
<span className="font-mono">{user?.login || 'unknown'}</span>
</div>
{status?.scope ? (
<div className="typography-micro text-muted-foreground truncate">Scopes: {status.scope}</div>
) : null}
</div>
</div>
<Button variant="outline" onClick={disconnect} disabled={isBusy}>
Disconnect
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-background/50 px-3 py-2">
<div className="typography-ui-label text-foreground">Not connected</div>
<Button onClick={startConnect} disabled={isBusy}>
Connect
</Button>
</div>
)}
{flow ? (
<div className="space-y-3 rounded-lg border bg-background/50 p-3">
<div className="space-y-1">
<div className="typography-ui-label text-foreground">Authorize OpenChamber</div>
<div className="typography-meta text-muted-foreground">
In GitHub, enter this code:
</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className="font-mono text-lg tracking-widest text-foreground">{flow.userCode}</div>
<Button variant="outline" asChild>
<a
href={flow.verificationUriComplete || flow.verificationUri}
target="_blank"
rel="noopener noreferrer"
>
Open GitHub
</a>
</Button>
</div>
<div className="typography-micro text-muted-foreground">
Waiting for approval (auto-refresh)
</div>
<div className="flex justify-end">
<Button variant="ghost" disabled={isBusy} onClick={() => {
stopPolling();
setFlow(null);
}}>
Cancel
</Button>
</div>
</div>
) : null}
</div>
);
};
@@ -7,6 +7,7 @@ import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
import { WorktreeSectionContent } from './WorktreeSectionContent';
import { NotificationSettings } from './NotificationSettings';
import { GitHubSettings } from './GitHubSettings';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDeviceInfo } from '@/lib/device';
import { isWebRuntime } from '@/lib/desktop';
@@ -56,6 +57,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
return <SessionsSectionContent />;
case 'git':
return <GitSectionContent />;
case 'github':
return <GitHubSectionContent />;
case 'notifications':
return <NotificationSectionContent />;
default:
@@ -117,6 +120,11 @@ const GitSectionContent: React.FC = () => {
);
};
// GitHub section: Connect account for PR/issue workflows
const GitHubSectionContent: React.FC = () => {
return <GitHubSettings />;
};
// Notifications section: Native browser notifications
const NotificationSectionContent: React.FC = () => {
return <NotificationSettings />;
@@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { AboutSettings } from './AboutSettings';
import { cn } from '@/lib/utils';
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'notifications';
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'github' | 'notifications';
interface OpenChamberSidebarProps {
selectedSection: OpenChamberSection;
@@ -42,6 +42,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
items: ['Commit Messages', 'Worktree'],
hideInVSCode: true,
},
{
id: 'github',
label: 'GitHub',
items: ['Connect', 'PRs', 'Issues'],
},
{
id: 'notifications',
label: 'Notifications',
@@ -5,6 +5,7 @@ import { useFireworksCelebration } from '@/contexts/FireworksContext';
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import {
useGitStore,
useGitStatus,
@@ -39,6 +40,7 @@ import { GitEmptyState } from './git/GitEmptyState';
import { ChangesSection } from './git/ChangesSection';
import { CommitSection } from './git/CommitSection';
import { HistorySection } from './git/HistorySection';
import { PullRequestSection } from './git/PullRequestSection';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
@@ -231,6 +233,15 @@ export const GitView: React.FC = () => {
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const baseBranch = React.useMemo(() => {
const fromProject = activeProject?.worktreeDefaults?.baseBranch;
if (typeof fromProject === 'string' && fromProject.trim().length > 0) {
return fromProject.trim();
}
return 'main';
}, [activeProject?.worktreeDefaults?.baseBranch]);
const [commitMessage, setCommitMessage] = React.useState(
initialSnapshot?.commitMessage ?? ''
);
@@ -1038,6 +1049,14 @@ export const GitView: React.FC = () => {
)}
</div>
{currentDirectory && status?.current ? (
<PullRequestSection
directory={currentDirectory}
branch={status.current}
baseBranch={baseBranch}
/>
) : null}
{/* History below, constrained width */}
<HistorySection
log={log}
@@ -11,7 +11,6 @@ import {
} from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { CommitInput } from './CommitInput';
import { AIHighlightsBox } from './AIHighlightsBox';
@@ -97,32 +96,25 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
)}
<div className="flex items-center gap-2">
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onGenerateMessage}
disabled={
isGeneratingMessage ||
commitAction !== null ||
selectedCount === 0 ||
isBusy
}
aria-label="Generate commit message"
>
{isGeneratingMessage ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiAiGenerate2 className="size-4 text-primary" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>
Generate commit message with AI
</TooltipContent>
</Tooltip>
<Button
variant="outline"
size="sm"
onClick={onGenerateMessage}
disabled={
isGeneratingMessage ||
commitAction !== null ||
selectedCount === 0 ||
isBusy
}
type="button"
>
{isGeneratingMessage ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiAiGenerate2 className="size-4 text-primary" />
)}
Generate
</Button>
<div className="flex-1" />
@@ -0,0 +1,440 @@
import React from 'react';
import {
RiAiGenerate2,
RiCheckboxBlankLine,
RiCheckboxLine,
RiExternalLinkLine,
RiGitPullRequestLine,
RiLoader4Line,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { generatePullRequestDescription } from '@/lib/gitApi';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type {
GitHubPullRequest,
GitHubPullRequestStatus,
} from '@/lib/api/types';
type MergeMethod = 'merge' | 'squash' | 'rebase';
const statusColor = (state: string | undefined | null): string => {
switch (state) {
case 'success':
return 'bg-[color:var(--status-success)]';
case 'failure':
return 'bg-[color:var(--status-error)]';
case 'pending':
return 'bg-[color:var(--status-warning)]';
default:
return 'bg-muted-foreground/40';
}
};
const branchToTitle = (branch: string): string => {
return branch
.replace(/^refs\/heads\//, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
};
const openExternal = async (url: string) => {
if (typeof window === 'undefined') return;
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
if (desktop?.openExternal) {
try {
await desktop.openExternal(url);
return;
} catch {
// fall through
}
}
try {
window.open(url, '_blank', 'noopener,noreferrer');
} catch {
// ignore
}
};
export const PullRequestSection: React.FC<{
directory: string;
branch: string;
baseBranch: string;
}> = ({ directory, branch, baseBranch }) => {
const { github } = useRuntimeAPIs();
const [isOpen, setIsOpen] = React.useState(true);
const [isLoading, setIsLoading] = React.useState(false);
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [title, setTitle] = React.useState(() => branchToTitle(branch));
const [body, setBody] = React.useState('');
const [draft, setDraft] = React.useState(false);
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
const [isGenerating, setIsGenerating] = React.useState(false);
const [isCreating, setIsCreating] = React.useState(false);
const [isMerging, setIsMerging] = React.useState(false);
const [isMarkingReady, setIsMarkingReady] = React.useState(false);
const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch);
const refresh = React.useCallback(async () => {
if (!canShow) return;
if (!github?.prStatus) {
setStatus(null);
setError('GitHub runtime API unavailable');
return;
}
setIsLoading(true);
setError(null);
try {
const next = await github.prStatus(directory, branch);
setStatus(next);
if (next.connected === false) {
setError(null);
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setError(message || 'Failed to load PR status');
} finally {
setIsLoading(false);
}
}, [branch, canShow, directory, github]);
React.useEffect(() => {
setTitle(branchToTitle(branch));
setBody('');
setDraft(false);
void refresh();
}, [branch, refresh]);
const generateDescription = React.useCallback(async () => {
if (isGenerating) return;
if (!directory) return;
setIsGenerating(true);
try {
const generated = await generatePullRequestDescription(directory, {
base: baseBranch,
head: branch,
});
if (generated.title?.trim()) {
setTitle(generated.title.trim());
}
if (generated.body?.trim()) {
setBody(generated.body.trim());
}
toast.success('PR description generated');
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to generate description', { description: message });
} finally {
setIsGenerating(false);
}
}, [baseBranch, branch, directory, isGenerating]);
const createPr = React.useCallback(async () => {
if (!github?.prCreate) {
toast.error('GitHub runtime API unavailable');
return;
}
const trimmedTitle = title.trim();
if (!trimmedTitle) {
toast.error('Title is required');
return;
}
setIsCreating(true);
try {
const pr = await github.prCreate({
directory,
title: trimmedTitle,
head: branch,
base: baseBranch,
...(body.trim() ? { body } : {}),
draft,
});
toast.success('PR created');
setStatus((prev) => (prev ? { ...prev, pr } : prev));
await refresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to create PR', { description: message });
} finally {
setIsCreating(false);
}
}, [baseBranch, body, branch, directory, draft, github, refresh, title]);
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prMerge) {
toast.error('GitHub runtime API unavailable');
return;
}
setIsMerging(true);
try {
const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod });
if (result.merged) {
toast.success('PR merged');
} else {
toast.message('PR not merged', { description: result.message || 'Not mergeable' });
}
await refresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Merge failed', { description: message });
if (pr.url) {
void openExternal(pr.url);
}
} finally {
setIsMerging(false);
}
}, [directory, github, mergeMethod, refresh]);
const markReady = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prReady) {
toast.error('GitHub runtime API unavailable');
return;
}
setIsMarkingReady(true);
try {
await github.prReady({ directory, number: pr.number });
toast.success('Marked ready for review');
await refresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to mark ready', { description: message });
if (pr.url) {
void openExternal(pr.url);
}
} finally {
setIsMarkingReady(false);
}
}, [directory, github, refresh]);
if (!canShow) {
return null;
}
const pr = status?.pr ?? null;
const repoUrl = status?.repo?.url || null;
const checks = status?.checks ?? null;
const canMerge = Boolean(status?.canMerge);
const isConnected = Boolean(status?.connected);
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<RiGitPullRequestLine className="size-4 text-muted-foreground" />
<h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3>
{pr ? (
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
) : null}
</div>
<div className="flex items-center gap-2">
{isLoading ? <RiLoader4Line className="size-4 animate-spin text-muted-foreground" /> : null}
{checks ? (
<span className="inline-flex items-center gap-2 typography-micro text-muted-foreground">
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
{checks.total > 0 ? `${checks.success}/${checks.total}` : checks.state}
</span>
) : null}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/40">
<div className="flex flex-col gap-3 p-3">
{!isConnected ? (
<div className="typography-meta text-muted-foreground">
GitHub not connected. Connect in Settings to create and merge PRs.
</div>
) : null}
{error ? (
<div className="space-y-2">
<div className="typography-ui-label text-foreground">PR status unavailable</div>
<div className="typography-meta text-muted-foreground break-words">{error}</div>
{repoUrl ? (
<Button variant="outline" size="sm" asChild className="w-fit">
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Open Repo
</a>
</Button>
) : null}
</div>
) : null}
{pr ? (
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">{pr.title}</div>
<div className="typography-micro text-muted-foreground truncate">
{pr.state}{pr.draft ? ' (draft)' : ''}
{pr.mergeable === false ? ' · not mergeable' : ''}
{typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''}
</div>
{canMerge && pr.draft ? (
<div className="typography-micro text-muted-foreground">
Draft PRs must be marked ready before merge.
</div>
) : null}
{!canMerge ? (
<div className="typography-micro text-muted-foreground">No merge permission; use Open in GitHub.</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a href={pr.url} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Open
</a>
</Button>
{canMerge && pr.draft && pr.state === 'open' ? (
<Button
variant="outline"
size="sm"
onClick={() => markReady(pr)}
disabled={isMarkingReady || isMerging}
>
{isMarkingReady ? <RiLoader4Line className="size-4 animate-spin" /> : null}
Ready
</Button>
) : null}
{canMerge ? (
<>
<select
className="h-8 rounded-md border border-border bg-background px-2 typography-meta"
value={mergeMethod}
onChange={(e) => setMergeMethod(e.target.value as MergeMethod)}
disabled={isMerging || pr.state !== 'open'}
>
<option value="squash">Squash</option>
<option value="merge">Merge</option>
<option value="rebase">Rebase</option>
</select>
<Button
size="sm"
onClick={() => mergePr(pr)}
disabled={isMerging || isMarkingReady || pr.state !== 'open' || pr.draft}
>
{isMerging ? <RiLoader4Line className="size-4 animate-spin" /> : null}
Merge
</Button>
</>
) : null}
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="typography-ui-label text-foreground">Create PR</div>
<div className="typography-micro text-muted-foreground truncate">
{branch} {baseBranch}
</div>
</div>
{repoUrl ? (
<Button variant="outline" size="sm" asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Repo
</a>
</Button>
) : null}
</div>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">Title</div>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="PR title"
/>
</label>
<label className="space-y-1">
<div className="typography-micro text-muted-foreground">Description</div>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
className="min-h-[110px] bg-background/80"
placeholder="What changed and why"
/>
</label>
<div
className="flex items-center gap-2 cursor-pointer"
role="button"
tabIndex={0}
aria-pressed={draft}
onClick={() => setDraft((v) => !v)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setDraft((v) => !v);
}
}}
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDraft((v) => !v);
}}
aria-label="Toggle draft PR"
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{draft ? (
<RiCheckboxLine className="size-4 text-primary" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
</button>
<span className="typography-ui-label text-foreground select-none">Draft</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={generateDescription}
disabled={isGenerating || isCreating}
>
{isGenerating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiAiGenerate2 className="size-4 text-primary" />}
Generate
</Button>
<div className="flex-1" />
<Button size="sm" onClick={createPr} disabled={isCreating || !isConnected}>
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : null}
Create PR
</Button>
</div>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};
+116
View File
@@ -269,6 +269,11 @@ export interface GeneratedCommitMessage {
highlights: string[];
}
export interface GeneratedPullRequestDescription {
title: string;
body: string;
}
export interface GitAPI {
checkIsGitRepository(directory: string): Promise<boolean>;
getGitStatus(directory: string): Promise<GitStatus>;
@@ -280,6 +285,10 @@ export interface GitAPI {
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>;
generatePullRequestDescription(
directory: string,
payload: { base: string; head: string }
): Promise<GeneratedPullRequestDescription>;
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }>;
@@ -478,6 +487,112 @@ export interface PushAPI {
setVisibility(payload: { visible: boolean }): Promise<{ ok: true } | null>;
}
export type GitHubUserSummary = {
login: string;
id?: number;
avatarUrl?: string;
name?: string;
email?: string;
};
export type GitHubRepoRef = {
owner: string;
repo: string;
url: string;
};
export type GitHubChecksSummary = {
state: 'success' | 'failure' | 'pending' | 'unknown';
total: number;
success: number;
failure: number;
pending: number;
};
export type GitHubPullRequest = {
number: number;
title: string;
url: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
base: string;
head: string;
headSha?: string;
mergeable?: boolean | null;
mergeableState?: string | null;
};
export type GitHubPullRequestStatus = {
connected: boolean;
repo?: GitHubRepoRef | null;
branch?: string;
pr?: GitHubPullRequest | null;
checks?: GitHubChecksSummary | null;
canMerge?: boolean;
};
export type GitHubPullRequestCreateInput = {
directory: string;
title: string;
head: string;
base: string;
body?: string;
draft?: boolean;
};
export type GitHubPullRequestMergeInput = {
directory: string;
number: number;
method: 'merge' | 'squash' | 'rebase';
};
export type GitHubPullRequestReadyInput = {
directory: string;
number: number;
};
export type GitHubPullRequestReadyResult = {
ready: boolean;
};
export type GitHubPullRequestMergeResult = {
merged: boolean;
message?: string;
};
export type GitHubAuthStatus = {
connected: boolean;
user?: GitHubUserSummary | null;
scope?: string;
};
export type GitHubDeviceFlowStart = {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
expiresIn: number;
interval: number;
scope?: string;
};
export type GitHubDeviceFlowComplete =
| { connected: true; user: GitHubUserSummary; scope?: string }
| { connected: false; status?: string; error?: string };
export interface GitHubAPI {
authStatus(): Promise<GitHubAuthStatus>;
authStart(): Promise<GitHubDeviceFlowStart>;
authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete>;
authDisconnect(): Promise<{ removed: boolean }>;
me?(): Promise<GitHubUserSummary>;
prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus>;
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
}
export interface RuntimeAPIs {
runtime: RuntimeDescriptor;
terminal: TerminalAPI;
@@ -486,6 +601,7 @@ export interface RuntimeAPIs {
settings: SettingsAPI;
permissions: PermissionsAPI;
notifications: NotificationsAPI;
github?: GitHubAPI;
push?: PushAPI;
diagnostics?: DiagnosticsAPI;
tools: ToolsAPI;
+11
View File
@@ -104,6 +104,17 @@ export async function generateCommitMessage(
return gitHttp.generateCommitMessage(directory, files);
}
export async function generatePullRequestDescription(
directory: string,
payload: { base: string; head: string }
): Promise<import('./api/types').GeneratedPullRequestDescription> {
const runtime = getRuntimeGit();
if (runtime?.generatePullRequestDescription) {
return runtime.generatePullRequestDescription(directory, payload);
}
return gitHttp.generatePullRequestDescription(directory, payload);
}
export async function listGitWorktrees(directory: string): Promise<import('./api/types').GitWorktreeInfo[]> {
const runtime = getRuntimeGit();
if (runtime) return runtime.listGitWorktrees(directory);
+29
View File
@@ -248,6 +248,35 @@ export async function generateCommitMessage(
};
}
export async function generatePullRequestDescription(
directory: string,
payload: { base: string; head: string }
): Promise<{ title: string; body: string }> {
const { base, head } = payload;
if (!base || !head) {
throw new Error('base and head are required');
}
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ base, head }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to generate PR description');
}
const data = await response.json().catch(() => null);
const title = typeof data?.title === 'string' ? data.title : '';
const body = typeof data?.body === 'string' ? data.body : '';
if (!title && !body) {
throw new Error('Malformed PR description response');
}
return { title, body };
}
export async function listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory));
if (!response.ok) {
+379
View File
@@ -12,6 +12,22 @@ import {
installSkillsFromRepository as installSkillsFromGit,
type SkillsCatalogSourceConfig,
} from './skillsCatalog';
import {
DEFAULT_GITHUB_CLIENT_ID,
DEFAULT_GITHUB_SCOPES,
clearGitHubAuth,
exchangeDeviceCode,
fetchMe,
readGitHubAuth,
startDeviceFlow,
writeGitHubAuth,
} from './githubAuth';
import {
createPullRequest,
getPullRequestStatus,
markPullRequestReady,
mergePullRequest,
} from './githubPr';
export interface BridgeRequest {
id: string;
@@ -77,6 +93,67 @@ const readSettings = (ctx?: BridgeContext) => {
};
};
const readStringField = (value: unknown, key: string): string => {
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
const candidate = record[key];
return typeof candidate === 'string' ? candidate.trim() : '';
};
const readBooleanField = (value: unknown, key: string): boolean | undefined => {
if (!value || typeof value !== 'object') return undefined;
const record = value as Record<string, unknown>;
const candidate = record[key];
return typeof candidate === 'boolean' ? candidate : undefined;
};
const readNumberField = (value: unknown, key: string): number | undefined => {
if (!value || typeof value !== 'object') return undefined;
const record = value as Record<string, unknown>;
const candidate = record[key];
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
};
const normalizeMergeMethod = (value: string): 'merge' | 'squash' | 'rebase' => {
const trimmed = value.trim();
if (trimmed === 'merge' || trimmed === 'squash' || trimmed === 'rebase') return trimmed;
return 'merge';
};
const extractZenOutputText = (value: unknown): string | null => {
if (!value || typeof value !== 'object') return null;
const root = value as Record<string, unknown>;
const output = root.output;
if (!Array.isArray(output)) return null;
const messageItem = output.find((item) => {
if (!item || typeof item !== 'object') return false;
return (item as Record<string, unknown>).type === 'message';
}) as Record<string, unknown> | undefined;
if (!messageItem) return null;
const content = messageItem.content;
if (!Array.isArray(content)) return null;
const textItem = content.find((item) => {
if (!item || typeof item !== 'object') return false;
return (item as Record<string, unknown>).type === 'output_text';
}) as Record<string, unknown> | undefined;
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
return text || null;
};
const parseJsonObjectSafe = (value: string): Record<string, unknown> | null => {
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
return null;
}
};
const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext) => {
const current = readSettings(ctx);
const restChanges = { ...(changes || {}) };
@@ -877,6 +954,223 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: true, data: updated };
}
case 'api:github/auth:status': {
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 } };
}
try {
const user = await fetchMe(stored.accessToken);
return { id, type, success: true, data: { connected: true, user, scope: stored.scope } };
} catch (error: unknown) {
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
const message = error instanceof Error ? error.message : String(error);
if (status === 401 || message === 'unauthorized') {
await clearGitHubAuth(context);
return { id, type, success: true, data: { connected: false } };
}
return { id, type, success: false, error: message };
}
}
case 'api:github/auth:start': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const settings = readSettings(ctx);
const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID;
const scopes = readStringField(settings, 'githubScopes') || DEFAULT_GITHUB_SCOPES;
const flow = await startDeviceFlow(clientId, scopes);
return { id, type, success: true, data: flow };
}
case 'api:github/auth:complete': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const deviceCode = readStringField(payload, 'deviceCode');
if (!deviceCode) return { id, type, success: false, error: 'deviceCode is required' };
const settings = readSettings(ctx);
const clientId = readStringField(settings, 'githubClientId') || DEFAULT_GITHUB_CLIENT_ID;
const token = await exchangeDeviceCode(clientId, deviceCode);
const tokenRecord = token && typeof token === 'object' ? (token as Record<string, unknown>) : null;
const tokenError = typeof tokenRecord?.error === 'string' ? tokenRecord.error : '';
const tokenErrorDescription = typeof tokenRecord?.error_description === 'string' ? tokenRecord.error_description : '';
if (tokenError) {
return {
id,
type,
success: true,
data: {
connected: false,
status: tokenError,
error: tokenErrorDescription || tokenError,
},
};
}
const accessToken = typeof tokenRecord?.access_token === 'string' ? tokenRecord.access_token : '';
if (!accessToken) {
return { id, type, success: false, error: 'Missing access_token from GitHub' };
}
const user = await fetchMe(accessToken);
await writeGitHubAuth(context, {
accessToken,
scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined,
tokenType: typeof tokenRecord?.token_type === 'string' ? tokenRecord.token_type : undefined,
createdAt: Date.now(),
user,
});
return {
id,
type,
success: true,
data: {
connected: true,
user,
scope: typeof tokenRecord?.scope === 'string' ? tokenRecord.scope : undefined,
},
};
}
case 'api:github/auth:disconnect': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const removed = await clearGitHubAuth(context);
return { id, type, success: true, data: { removed } };
}
case 'api:github/me': {
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: false, error: 'GitHub not connected' };
try {
const user = await fetchMe(stored.accessToken);
return { id, type, success: true, data: user };
} catch (error: unknown) {
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
const message = error instanceof Error ? error.message : String(error);
if (status === 401 || message === 'unauthorized') {
await clearGitHubAuth(context);
return { id, type, success: false, error: 'GitHub token expired or revoked' };
}
return { id, type, success: false, error: message };
}
}
case 'api:github/pr:status': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const directory = readStringField(payload, 'directory');
const branch = readStringField(payload, 'branch');
if (!directory || !branch) {
return { id, type, success: false, error: 'directory and branch are required' };
}
const stored = await readGitHubAuth(context);
if (!stored?.accessToken) {
return { id, type, success: true, data: { connected: false } };
}
try {
const result = await getPullRequestStatus(
stored.accessToken,
stored.user?.login || null,
directory,
branch,
);
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/pr:create': {
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: false, error: 'GitHub not connected' };
const directory = readStringField(payload, 'directory');
const title = readStringField(payload, 'title');
const head = readStringField(payload, 'head');
const base = readStringField(payload, 'base');
const body = readStringField(payload, 'body');
const draft = readBooleanField(payload, 'draft');
if (!directory || !title || !head || !base) {
return { id, type, success: false, error: 'directory, title, head, base are required' };
}
try {
const pr = await createPullRequest(stored.accessToken, directory, {
directory,
title,
head,
base,
...(body ? { body } : {}),
...(typeof draft === 'boolean' ? { draft } : {}),
});
return { id, type, success: true, data: pr };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { id, type, success: false, error: message };
}
}
case 'api:github/pr:merge': {
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: false, error: 'GitHub not connected' };
const directory = readStringField(payload, 'directory');
const method = normalizeMergeMethod(readStringField(payload, 'method') || 'merge');
const number = readNumberField(payload, 'number') ?? 0;
if (!directory || !number) {
return { id, type, success: false, error: 'directory and number are required' };
}
try {
const result = await mergePullRequest(stored.accessToken, directory, {
directory,
number,
method,
});
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/pr:ready': {
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: false, error: 'GitHub not connected' };
const directory = readStringField(payload, 'directory');
const number = readNumberField(payload, 'number') ?? 0;
if (!directory || !number) {
return { id, type, success: false, error: 'directory and number are required' };
}
try {
const result = await markPullRequestReady(stored.accessToken, directory, number);
return { id, type, success: true, data: result };
} catch (error: unknown) {
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
const message = error instanceof Error ? error.message : String(error);
if (status === 401 || message === 'unauthorized') {
await clearGitHubAuth(context);
}
return { id, type, success: false, error: message };
}
}
case 'api:config/reload': {
await ctx?.manager?.restart();
return { id, type, success: true, data: { restarted: true } };
@@ -1614,6 +1908,91 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: true, data: result };
}
case 'api:git/pr-description': {
const { directory, base, head } = (payload || {}) as {
directory?: string;
base?: string;
head?: string;
};
if (!directory) {
return { id, type, success: false, error: 'Directory is required' };
}
if (!base || !head) {
return { id, type, success: false, error: 'base and head are required' };
}
// Collect diffs (best-effort)
let files: string[] = [];
try {
const listed = await gitService.getGitRangeFiles(directory, base, head);
files = Array.isArray(listed) ? listed : [];
} catch {
files = [];
}
if (files.length === 0) {
return { id, type, success: false, error: 'No diffs available for base...head' };
}
let diffSummaries = '';
for (const file of files) {
try {
const diff = await gitService.getGitRangeDiff(directory, base, head, file, 3);
const raw = typeof diff?.diff === 'string' ? diff.diff : '';
if (!raw.trim()) continue;
diffSummaries += `FILE: ${file}\n${raw}\n\n`;
} catch {
// ignore
}
}
if (!diffSummaries.trim()) {
return { id, type, success: false, error: 'No diffs available for selected files' };
}
const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`;
try {
const response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-5-nano',
input: [{ role: 'user', content: prompt }],
max_output_tokens: 1200,
stream: false,
reasoning: { effort: 'low' },
}),
});
if (!response.ok) {
return { id, type, success: false, error: 'Failed to generate PR description' };
}
const data = await response.json().catch(() => null) as unknown;
const raw = extractZenOutputText(data);
if (!raw) {
return { id, type, success: false, error: 'No PR description returned by generator' };
}
const cleaned = String(raw)
.trim()
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
const parsed = parseJsonObjectSafe(cleaned) || parseJsonObjectSafe(raw);
if (parsed) {
const title = typeof parsed.title === 'string' ? parsed.title : '';
const body = typeof parsed.body === 'string' ? parsed.body : '';
return { id, type, success: true, data: { title, body } };
}
return { id, type, success: true, data: { title: '', body: String(raw) } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { id, type, success: false, error: message };
}
}
case 'api:git/identity': {
const { directory, method, userName, userEmail, sshKey } = (payload || {}) as {
directory?: string;
+42
View File
@@ -684,6 +684,48 @@ export async function getGitDiff(
return { diff: result.stdout };
}
/**
* Get diff between two refs for a file (base...head).
*/
export async function getGitRangeDiff(
directory: string,
base: string,
head: string,
filePath: string,
contextLines = 3
): Promise<{ diff: string }> {
const baseRef = (base || '').trim();
const headRef = (head || '').trim();
if (!baseRef || !headRef) {
return { diff: '' };
}
const args = ['diff', '--no-color', `-U${Math.max(0, contextLines)}`, `${baseRef}...${headRef}`, '--', filePath];
const result = await execGit(args, directory);
return { diff: result.stdout };
}
/**
* List files changed between two refs (base...head).
*/
export async function getGitRangeFiles(
directory: string,
base: string,
head: string
): Promise<string[]> {
const baseRef = (base || '').trim();
const headRef = (head || '').trim();
if (!baseRef || !headRef) {
return [];
}
const args = ['diff', '--name-only', `${baseRef}...${headRef}`];
const result = await execGit(args, directory);
if (result.exitCode !== 0) return [];
return String(result.stdout || '')
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
}
/**
* Get file diff with original and modified content
*/
+173
View File
@@ -0,0 +1,173 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs/promises';
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const API_USER_URL = 'https://api.github.com/user';
const API_EMAILS_URL = 'https://api.github.com/user/emails';
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
export const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
export const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
type StoredAuth = {
accessToken: string;
scope?: string;
tokenType?: string;
createdAt?: number;
user?: { login: string; id?: number; avatarUrl?: string };
};
type JsonRecord = Record<string, unknown>;
type DeviceCodeResponse = {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
expires_in: number;
interval: number;
};
type TokenResponse = {
access_token?: string;
scope?: string;
token_type?: string;
error?: string;
error_description?: string;
};
const authFilePath = (context: vscode.ExtensionContext) =>
path.join(context.globalStorageUri.fsPath, 'github-auth.json');
export const readGitHubAuth = async (context: vscode.ExtensionContext): Promise<StoredAuth | null> => {
try {
const raw = await fs.readFile(authFilePath(context), 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
const token = typeof parsed.accessToken === 'string' ? parsed.accessToken : '';
if (!token) return null;
return parsed as StoredAuth;
} catch {
return null;
}
};
export const writeGitHubAuth = async (context: vscode.ExtensionContext, auth: StoredAuth): Promise<void> => {
await fs.mkdir(context.globalStorageUri.fsPath, { recursive: true });
await fs.writeFile(authFilePath(context), JSON.stringify(auth, null, 2), 'utf8');
try {
// best-effort perms on unix
await fs.chmod(authFilePath(context), 0o600);
} catch {
// ignore
}
};
export const clearGitHubAuth = async (context: vscode.ExtensionContext): Promise<boolean> => {
try {
await fs.rm(authFilePath(context));
return true;
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && (err as { code?: string }).code === 'ENOENT') return true;
return false;
}
};
const postForm = async <T extends JsonRecord>(url: string, params: Record<string, string>): Promise<T> => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'User-Agent': 'OpenChamber',
},
body: new URLSearchParams(params).toString(),
});
const payload = (await response.json().catch(() => null)) as T | null;
if (!response.ok) {
const errorDescription = typeof payload?.error_description === 'string' ? payload.error_description : '';
const error = typeof payload?.error === 'string' ? payload.error : '';
throw new Error(errorDescription || error || response.statusText);
}
return payload as T;
};
export const startDeviceFlow = async (clientId: string, scope: string) => {
const payload = await postForm<DeviceCodeResponse>(DEVICE_CODE_URL, { client_id: clientId, scope });
return {
deviceCode: payload.device_code,
userCode: payload.user_code,
verificationUri: payload.verification_uri,
verificationUriComplete: payload.verification_uri_complete,
expiresIn: payload.expires_in,
interval: payload.interval,
scope,
};
};
export const exchangeDeviceCode = async (clientId: string, deviceCode: string) => {
const payload = await postForm<TokenResponse>(ACCESS_TOKEN_URL, {
client_id: clientId,
device_code: deviceCode,
grant_type: DEVICE_GRANT_TYPE,
});
return payload;
};
export const fetchMe = async (accessToken: string) => {
const response = await fetch(API_USER_URL, {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'OpenChamber',
},
});
if (response.status === 401) {
const error = new Error('unauthorized');
(error as unknown as { status?: number }).status = 401;
throw error;
}
const payload = (await response.json().catch(() => null)) as JsonRecord | null;
if (!response.ok || !payload) {
throw new Error(`GitHub /user failed: ${response.statusText}`);
}
const name = typeof payload.name === 'string' ? payload.name : undefined;
let email = typeof payload.email === 'string' ? payload.email : undefined;
if (!email) {
try {
const emailsResponse = await fetch(API_EMAILS_URL, {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'OpenChamber',
},
});
if (emailsResponse.status === 401) {
const error = new Error('unauthorized');
(error as unknown as { status?: number }).status = 401;
throw error;
}
const list = (await emailsResponse.json().catch(() => null)) as Array<Record<string, unknown>> | null;
if (emailsResponse.ok && Array.isArray(list)) {
const primaryVerified = list.find((e) => Boolean(e?.primary) && Boolean(e?.verified) && typeof e?.email === 'string');
const anyVerified = list.find((e) => Boolean(e?.verified) && typeof e?.email === 'string');
email = (primaryVerified?.email as string | undefined) || (anyVerified?.email as string | undefined);
}
} catch {
// ignore
}
}
return {
login: String(payload.login || ''),
id: typeof payload.id === 'number' ? payload.id : undefined,
avatarUrl: typeof payload.avatar_url === 'string' ? payload.avatar_url : undefined,
name,
email,
};
};
+338
View File
@@ -0,0 +1,338 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const API_BASE = 'https://api.github.com';
type JsonRecord = Record<string, unknown>;
type GitHubRepoRef = { owner: string; repo: string; url: string };
type GitHubChecksSummary = {
state: 'success' | 'failure' | 'pending' | 'unknown';
total: number;
success: number;
failure: number;
pending: number;
};
type GitHubPullRequest = {
number: number;
title: string;
url: string;
state: 'open' | 'closed' | 'merged';
draft: boolean;
base: string;
head: string;
headSha?: string;
mergeable?: boolean | null;
mergeableState?: string | null;
};
type GitHubPullRequestStatus = {
connected: boolean;
repo?: GitHubRepoRef | null;
branch?: string;
pr?: GitHubPullRequest | null;
checks?: GitHubChecksSummary | null;
canMerge?: boolean;
};
type GitHubPullRequestCreateInput = {
directory: string;
title: string;
head: string;
base: string;
body?: string;
draft?: boolean;
};
type GitHubPullRequestMergeInput = {
directory: string;
number: number;
method: 'merge' | 'squash' | 'rebase';
};
type GitHubPullRequestMergeResult = { merged: boolean; message?: string };
const parseGitHubRemoteUrl = (raw: string): GitHubRepoRef | null => {
const value = raw.trim();
if (!value) return null;
if (value.startsWith('git@github.com:')) {
const rest = value.slice('git@github.com:'.length);
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
}
if (value.startsWith('ssh://git@github.com/')) {
const rest = value.slice('ssh://git@github.com/'.length);
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
}
try {
const url = new URL(value);
if (url.hostname !== 'github.com') return null;
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
} catch {
return null;
}
};
const getOriginRemoteUrl = async (directory: string): Promise<string | null> => {
try {
const { stdout } = await execFileAsync('git', ['-C', directory, 'remote', 'get-url', 'origin']);
const url = String(stdout || '').trim();
return url || null;
} catch {
return null;
}
};
export const resolveRepoFromDirectory = async (directory: string): Promise<GitHubRepoRef | null> => {
const remote = await getOriginRemoteUrl(directory);
if (!remote) return null;
return parseGitHubRemoteUrl(remote);
};
const githubFetch = async (
url: string,
accessToken: string,
init?: RequestInit,
): Promise<Response> => {
return fetch(url, {
...init,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'OpenChamber',
...(init?.headers || {}),
},
});
};
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
return (await response.json().catch(() => null)) as T | null;
};
const readString = (value: unknown): string => (typeof value === 'string' ? value : '');
export const getPullRequestStatus = async (
accessToken: string,
userLogin: string | null,
directory: string,
branch: string,
): Promise<GitHubPullRequestStatus> => {
const repo = await resolveRepoFromDirectory(directory);
if (!repo) {
return { connected: true, repo: null, branch, pr: null, checks: null, canMerge: false };
}
const listUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
listUrl.searchParams.set('state', 'open');
listUrl.searchParams.set('head', `${repo.owner}:${branch}`);
listUrl.searchParams.set('per_page', '10');
const listResp = await githubFetch(listUrl.toString(), accessToken);
if (listResp.status === 401) {
return { connected: false };
}
const list = await jsonOrNull<Array<{ number: number }>>(listResp);
if (!listResp.ok || !Array.isArray(list) || list.length === 0) {
return { connected: true, repo, branch, pr: null, checks: null, canMerge: false };
}
const number = list[0].number;
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<JsonRecord>(prResp);
if (!prResp.ok || !prJson) {
throw new Error('Failed to load PR');
}
const merged = Boolean(prJson.merged);
const prState = readString(prJson.state);
const state = merged ? 'merged' : (prState === 'closed' ? 'closed' : 'open');
const pr: GitHubPullRequest = {
number: typeof prJson.number === 'number' ? prJson.number : 0,
title: readString(prJson.title) || '',
url: readString(prJson.html_url) || '',
state,
draft: Boolean(prJson.draft),
base: readString((prJson.base as JsonRecord | undefined)?.ref) || '',
head: readString((prJson.head as JsonRecord | undefined)?.ref) || '',
headSha: readString((prJson.head as JsonRecord | undefined)?.sha) || undefined,
mergeable: typeof prJson.mergeable === 'boolean' ? prJson.mergeable : null,
mergeableState: readString(prJson.mergeable_state) || undefined,
};
let checks: GitHubChecksSummary | null = null;
if (pr.headSha) {
const statusResp = await githubFetch(
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/status`,
accessToken,
);
const statusJson = await jsonOrNull<JsonRecord>(statusResp);
if (statusResp.ok && statusJson) {
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 state2 = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
checks = { state: state2, total, ...counts };
}
}
let canMerge = false;
if (userLogin) {
const permResp = await githubFetch(
`${API_BASE}/repos/${repo.owner}/${repo.repo}/collaborators/${encodeURIComponent(userLogin)}/permission`,
accessToken,
);
const permJson = await jsonOrNull<{ permission?: string }>(permResp);
const perm = typeof permJson?.permission === 'string' ? permJson.permission : '';
canMerge = perm === 'admin' || perm === 'maintain' || perm === 'write';
}
return { connected: true, repo, branch, pr, checks, canMerge };
};
export const createPullRequest = async (
accessToken: string,
directory: string,
payload: GitHubPullRequestCreateInput,
): Promise<GitHubPullRequest> => {
const repo = await resolveRepoFromDirectory(directory);
if (!repo) {
throw new Error('Unable to resolve GitHub repo from git remote');
}
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`, accessToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
head: payload.head,
base: payload.base,
...(typeof payload.body === 'string' ? { body: payload.body } : {}),
...(typeof payload.draft === 'boolean' ? { draft: payload.draft } : {}),
}),
});
const json = await jsonOrNull<JsonRecord>(resp);
if (!resp.ok || !json) {
throw new Error('Failed to create PR');
}
return {
number: typeof json.number === 'number' ? json.number : 0,
title: readString(json.title) || '',
url: readString(json.html_url) || '',
state: readString(json.state) === 'closed' ? 'closed' : 'open',
draft: Boolean(json.draft),
base: readString((json.base as JsonRecord | undefined)?.ref) || payload.base,
head: readString((json.head as JsonRecord | undefined)?.ref) || payload.head,
headSha: readString((json.head as JsonRecord | undefined)?.sha) || undefined,
mergeable: typeof json.mergeable === 'boolean' ? json.mergeable : null,
mergeableState: readString(json.mergeable_state) || undefined,
};
};
export const mergePullRequest = async (
accessToken: string,
directory: string,
payload: GitHubPullRequestMergeInput,
): Promise<GitHubPullRequestMergeResult> => {
const repo = await resolveRepoFromDirectory(directory);
if (!repo) {
throw new Error('Unable to resolve GitHub repo from git remote');
}
const resp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${payload.number}/merge`, accessToken, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ merge_method: payload.method }),
});
if (resp.status === 403) {
throw new Error('Not authorized to merge this PR');
}
if (resp.status === 405 || resp.status === 409) {
return { merged: false, message: 'PR not mergeable' };
}
const json = await jsonOrNull<JsonRecord>(resp);
if (!resp.ok || !json) {
throw new Error('Failed to merge PR');
}
return { merged: Boolean(json.merged), message: readString(json.message) || undefined };
};
export const markPullRequestReady = async (
accessToken: string,
directory: string,
number: number,
): Promise<{ ready: boolean }> => {
const repo = await resolveRepoFromDirectory(directory);
if (!repo) {
throw new Error('Unable to resolve GitHub repo from git remote');
}
const prResp = await githubFetch(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`, accessToken);
if (prResp.status === 401) {
return { ready: false };
}
const prJson = await jsonOrNull<JsonRecord>(prResp);
const nodeId = typeof prJson?.node_id === 'string' ? prJson.node_id : '';
const isDraft = Boolean((prJson as Record<string, unknown> | null)?.draft);
if (!prResp.ok || !nodeId) {
throw new Error('Failed to resolve PR node id');
}
if (!isDraft) {
return { ready: true };
}
const resp = await githubFetch(`${API_BASE}/graphql`, accessToken, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query:
'mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { pullRequest { id isDraft } } }',
variables: { pullRequestId: nodeId },
}),
});
if (resp.status === 403) {
throw new Error('Not authorized to mark PR ready');
}
if (resp.status === 401) {
const error = new Error('unauthorized');
(error as unknown as { status?: number }).status = 401;
throw error;
}
const json = await jsonOrNull<JsonRecord>(resp);
if (!resp.ok || !json) {
throw new Error('Failed to mark PR ready');
}
if (json.errors) {
throw new Error('GitHub GraphQL error');
}
return { ready: true };
};
+12
View File
@@ -15,6 +15,7 @@ import type {
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GeneratedCommitMessage,
GeneratedPullRequestDescription,
GitWorktreeInfo,
GitAddWorktreePayload,
GitRemoveWorktreePayload,
@@ -96,6 +97,17 @@ export const createVSCodeGitAPI = (): GitAPI => ({
};
},
generatePullRequestDescription: async (
directory: string,
payload: { base: string; head: string }
): Promise<GeneratedPullRequestDescription> => {
return sendBridgeMessage<GeneratedPullRequestDescription>('api:git/pr-description', {
directory,
base: payload.base,
head: payload.head,
});
},
listGitWorktrees: async (directory: string): Promise<GitWorktreeInfo[]> => {
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
},
+34
View File
@@ -0,0 +1,34 @@
import type {
GitHubAPI,
GitHubAuthStatus,
GitHubPullRequest,
GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput,
GitHubPullRequestMergeResult,
GitHubPullRequestReadyInput,
GitHubPullRequestReadyResult,
GitHubPullRequestStatus,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubUserSummary,
} from '@openchamber/ui/lib/api/types';
import { sendBridgeMessage } from './bridge';
export const createVSCodeGitHubAPI = (): GitHubAPI => ({
authStatus: async () => sendBridgeMessage<GitHubAuthStatus>('api:github/auth:status'),
authStart: async () => sendBridgeMessage<GitHubDeviceFlowStart>('api:github/auth:start'),
authComplete: async (deviceCode: string) =>
sendBridgeMessage<GitHubDeviceFlowComplete>('api:github/auth:complete', { deviceCode }),
authDisconnect: async () => sendBridgeMessage<{ removed: boolean }>('api:github/auth:disconnect'),
me: async () => sendBridgeMessage<GitHubUserSummary>('api:github/me'),
prStatus: async (directory: string, branch: string) =>
sendBridgeMessage<GitHubPullRequestStatus>('api:github/pr:status', { directory, branch }),
prCreate: async (payload: GitHubPullRequestCreateInput) =>
sendBridgeMessage<GitHubPullRequest>('api:github/pr:create', payload),
prMerge: async (payload: GitHubPullRequestMergeInput) =>
sendBridgeMessage<GitHubPullRequestMergeResult>('api:github/pr:merge', payload),
prReady: async (payload: GitHubPullRequestReadyInput) =>
sendBridgeMessage<GitHubPullRequestReadyResult>('api:github/pr:ready', payload),
});
+2
View File
@@ -6,6 +6,7 @@ import { createVSCodeToolsAPI } from './tools';
import { createVSCodeEditorAPI } from './editor';
import { createVSCodeGitAPI } from './git';
import { createVSCodeActionsAPI } from './vscode';
import { createVSCodeGitHubAPI } from './github';
// Stub APIs return sensible defaults instead of throwing
const createStubTerminalAPI = (): TerminalAPI => ({
@@ -29,6 +30,7 @@ export const createVSCodeAPIs = (): RuntimeAPIs => ({
settings: createVSCodeSettingsAPI(),
permissions: createVSCodePermissionsAPI(),
notifications: createStubNotificationsAPI(),
github: createVSCodeGitHubAPI(),
tools: createVSCodeToolsAPI(),
editor: createVSCodeEditorAPI(),
vscode: createVSCodeActionsAPI(),
+568
View File
@@ -721,6 +721,18 @@ const sanitizeSettingsUpdate = (payload) => {
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
result.markdownDisplayMode = candidate.markdownDisplayMode;
}
if (typeof candidate.githubClientId === 'string') {
const trimmed = candidate.githubClientId.trim();
if (trimmed.length > 0) {
result.githubClientId = trimmed;
}
}
if (typeof candidate.githubScopes === 'string') {
const trimmed = candidate.githubScopes.trim();
if (trimmed.length > 0) {
result.githubScopes = trimmed;
}
}
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
@@ -3839,6 +3851,471 @@ async function main(options = {}) {
return authLibrary;
};
// ================= GitHub OAuth (Device Flow) =================
// Note: scopes may be overridden via OPENCHAMBER_GITHUB_SCOPES or settings.json (see github-auth.js).
let githubLibraries = null;
const getGitHubLibraries = async () => {
if (!githubLibraries) {
const [auth, device, octokit] = await Promise.all([
import('./lib/github-auth.js'),
import('./lib/github-device-flow.js'),
import('./lib/github-octokit.js'),
]);
githubLibraries = { ...auth, ...device, ...octokit };
}
return githubLibraries;
};
const getGitHubUserSummary = async (octokit) => {
const me = await octokit.rest.users.getAuthenticated();
let email = typeof me.data.email === 'string' ? me.data.email : null;
if (!email) {
try {
const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 });
const list = Array.isArray(emails?.data) ? emails.data : [];
const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string');
const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string');
email = primaryVerified?.email || anyVerified?.email || null;
} catch {
// ignore (scope might be missing)
}
}
return {
login: me.data.login,
id: me.data.id,
avatarUrl: me.data.avatar_url,
name: typeof me.data.name === 'string' ? me.data.name : null,
email,
};
};
app.get('/api/github/auth/status', async (_req, res) => {
try {
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
const auth = getGitHubAuth();
if (!auth?.accessToken) {
return res.json({ connected: false });
}
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
}
let user = null;
try {
user = await getGitHubUserSummary(octokit);
} catch (error) {
if (error?.status === 401) {
clearGitHubAuth();
return res.json({ connected: false });
}
}
const fallback = auth.user;
const mergedUser = user || fallback;
return res.json({
connected: true,
user: mergedUser,
scope: auth.scope,
});
} catch (error) {
console.error('Failed to get GitHub auth status:', error);
return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' });
}
});
app.post('/api/github/auth/start', async (_req, res) => {
try {
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
});
}
const scope = getGitHubScopes();
const payload = await startDeviceFlow({
clientId,
scope,
});
return res.json({
deviceCode: payload.device_code,
userCode: payload.user_code,
verificationUri: payload.verification_uri,
verificationUriComplete: payload.verification_uri_complete,
expiresIn: payload.expires_in,
interval: payload.interval,
scope,
});
} catch (error) {
console.error('Failed to start GitHub device flow:', error);
return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' });
}
});
app.post('/api/github/auth/complete', async (req, res) => {
try {
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
});
}
const deviceCode = typeof req.body?.deviceCode === 'string'
? req.body.deviceCode
: (typeof req.body?.device_code === 'string' ? req.body.device_code : '');
if (!deviceCode) {
return res.status(400).json({ error: 'deviceCode is required' });
}
const payload = await exchangeDeviceCode({ clientId, deviceCode });
if (payload?.error) {
return res.json({
connected: false,
status: payload.error,
error: payload.error_description || payload.error,
});
}
const accessToken = payload?.access_token;
if (!accessToken) {
return res.status(500).json({ error: 'Missing access_token from GitHub' });
}
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: accessToken });
const user = await getGitHubUserSummary(octokit);
setGitHubAuth({
accessToken,
scope: typeof payload.scope === 'string' ? payload.scope : '',
tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
user,
});
return res.json({
connected: true,
user,
scope: typeof payload.scope === 'string' ? payload.scope : '',
});
} catch (error) {
console.error('Failed to complete GitHub device flow:', error);
return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' });
}
});
app.delete('/api/github/auth', async (_req, res) => {
try {
const { clearGitHubAuth } = await getGitHubLibraries();
const removed = clearGitHubAuth();
return res.json({ success: true, removed });
} catch (error) {
console.error('Failed to disconnect GitHub:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' });
}
});
app.get('/api/github/me', async (_req, res) => {
try {
const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
let user;
try {
user = await getGitHubUserSummary(octokit);
} catch (error) {
if (error?.status === 401) {
clearGitHubAuth();
return res.status(401).json({ error: 'GitHub token expired or revoked' });
}
throw error;
}
return res.json(user);
} catch (error) {
console.error('Failed to fetch GitHub user:', error);
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' });
}
});
// ================= GitHub PR APIs =================
app.get('/api/github/pr/status', async (req, res) => {
try {
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
if (!directory || !branch) {
return res.status(400).json({ error: 'directory and branch are required' });
}
const { getOctokitOrNull, getGitHubAuth } = 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, branch, pr: null, checks: null, canMerge: false });
}
// Find PR for this branch (same-repo assumption)
const list = await octokit.rest.pulls.list({
owner: repo.owner,
repo: repo.repo,
state: 'open',
head: `${repo.owner}:${branch}`,
per_page: 10,
});
const first = Array.isArray(list?.data) ? list.data[0] : null;
if (!first) {
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
}
// Enrich with mergeability fields
const prFull = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: first.number });
const prData = prFull?.data;
if (!prData) {
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
}
// Checks summary (combined status)
let checks = null;
try {
const combined = await octokit.rest.repos.getCombinedStatusForRef({
owner: repo.owner,
repo: repo.repo,
ref: prData.head?.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;
}
// Permission check (best-effort)
let canMerge = false;
try {
const auth = getGitHubAuth();
const username = auth?.user?.login;
if (username) {
const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
owner: repo.owner,
repo: repo.repo,
username,
});
const level = perm?.data?.permission;
canMerge = level === 'admin' || level === 'maintain' || level === 'write';
}
} catch {
canMerge = false;
}
const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
return res.json({
connected: true,
repo,
branch,
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,
},
checks,
canMerge,
});
} catch (error) {
if (error?.status === 401) {
const { clearGitHubAuth } = await getGitHubLibraries();
clearGitHubAuth();
return res.json({ connected: false });
}
console.error('Failed to load GitHub PR status:', error);
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' });
}
});
app.post('/api/github/pr/create', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
if (!directory || !title || !head || !base) {
return res.status(400).json({ error: 'directory, title, head, base are required' });
}
const { getOctokitOrNull } = await getGitHubLibraries();
const octokit = getOctokitOrNull();
if (!octokit) {
return res.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
const created = await octokit.rest.pulls.create({
owner: repo.owner,
repo: repo.repo,
title,
head,
base,
...(typeof body === 'string' ? { body } : {}),
...(typeof draft === 'boolean' ? { draft } : {}),
});
const pr = created?.data;
if (!pr) {
return res.status(500).json({ error: 'Failed to create PR' });
}
return res.json({
number: pr.number,
title: pr.title,
url: pr.html_url,
state: pr.state === 'closed' ? 'closed' : 'open',
draft: Boolean(pr.draft),
base: pr.base?.ref,
head: pr.head?.ref,
headSha: pr.head?.sha,
mergeable: pr.mergeable,
mergeableState: pr.mergeable_state,
});
} catch (error) {
console.error('Failed to create GitHub PR:', error);
return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
}
});
app.post('/api/github/pr/merge', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
const method = typeof req.body?.method === 'string' ? req.body.method : 'merge';
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.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
try {
const result = await octokit.rest.pulls.merge({
owner: repo.owner,
repo: repo.repo,
pull_number: number,
merge_method: method,
});
return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
} catch (error) {
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to merge this PR' });
}
if (error?.status === 405 || error?.status === 409) {
return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
}
throw error;
}
} catch (error) {
console.error('Failed to merge GitHub PR:', error);
return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
}
});
app.post('/api/github/pr/ready', async (req, res) => {
try {
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
const number = typeof req.body?.number === 'number' ? req.body.number : null;
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.status(401).json({ error: 'GitHub not connected' });
}
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
const { repo } = await resolveGitHubRepoFromDirectory(directory);
if (!repo) {
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
}
const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
const nodeId = pr?.data?.node_id;
if (!nodeId) {
return res.status(500).json({ error: 'Failed to resolve PR node id' });
}
if (pr?.data?.draft === false) {
return res.json({ ready: true });
}
try {
await octokit.graphql(
`mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
{ pullRequestId: nodeId }
);
} catch (error) {
if (error?.status === 403) {
return res.status(403).json({ error: 'Not authorized to mark PR ready' });
}
throw error;
}
return res.json({ ready: true });
} catch (error) {
console.error('Failed to mark PR ready:', error);
return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
}
});
app.get('/api/provider/:providerId/source', async (req, res) => {
try {
const { providerId } = req.params;
@@ -4313,6 +4790,97 @@ async function main(options = {}) {
}
});
app.post('/api/git/pr-description', async (req, res) => {
const { getRangeDiff, getRangeFiles } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory || typeof directory !== 'string') {
return res.status(400).json({ error: 'directory parameter is required' });
}
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
if (!base || !head) {
return res.status(400).json({ error: 'base and head are required' });
}
const filesToDiff = await getRangeFiles(directory, { base, head });
const diffs = [];
for (const filePath of filesToDiff) {
const diff = await getRangeDiff(directory, { base, head, path: filePath, contextLines: 3 }).catch(() => '');
if (diff && diff.trim().length > 0) {
diffs.push({ path: filePath, diff });
}
}
if (diffs.length === 0) {
return res.status(400).json({ error: 'No diffs available for selected files' });
}
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`;
const model = 'gpt-5-nano';
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
let response;
try {
response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
input: [{ role: 'user', content: prompt }],
max_output_tokens: 1200,
stream: false,
reasoning: { effort: 'low' },
}),
signal: completionTimeout.signal,
});
} finally {
completionTimeout.cleanup();
}
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
console.error('PR description generation failed:', errorBody);
return res.status(502).json({ error: 'Failed to generate PR description' });
}
const data = await response.json();
const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
if (!raw) {
return res.status(502).json({ error: 'No PR description returned by generator' });
}
const cleanedJson = stripJsonMarkdownWrapper(raw);
const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
return candidate && array.indexOf(candidate) === index;
});
for (const candidate of candidates) {
if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
continue;
}
try {
const parsed = JSON.parse(candidate);
const title = typeof parsed?.title === 'string' ? parsed.title : '';
const body = typeof parsed?.body === 'string' ? parsed.body : '';
return res.json({ title, body });
} catch (parseError) {
console.warn('PR description generation returned non-JSON body:', parseError);
}
}
return res.json({ title: '', body: raw });
} catch (error) {
console.error('Failed to generate PR description:', error);
return res.status(500).json({ error: error.message || 'Failed to generate PR description' });
}
});
app.post('/api/git/pull', async (req, res) => {
const { pull } = await getGitLibraries();
try {
+34
View File
@@ -432,6 +432,40 @@ export async function getDiff(directory, { path, staged = false, contextLines =
}
}
export async function getRangeDiff(directory, { base, head, path, contextLines = 3 } = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) {
throw new Error('base and head are required');
}
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
}
args.push(`${baseRef}...${headRef}`);
if (path) {
args.push('--', path);
}
const diff = await git.raw(args);
return diff;
}
export async function getRangeFiles(directory, { base, head } = {}) {
const git = simpleGit(normalizeDirectoryPath(directory));
const baseRef = typeof base === 'string' ? base.trim() : '';
const headRef = typeof head === 'string' ? head.trim() : '';
if (!baseRef || !headRef) {
throw new Error('base and head are required');
}
const raw = await git.raw(['diff', '--name-only', `${baseRef}...${headRef}`]);
return String(raw || '')
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
function isImageFile(filePath) {
+149
View File
@@ -0,0 +1,149 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
const STORAGE_FILE = path.join(STORAGE_DIR, 'github-auth.json');
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
function ensureStorageDir() {
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
}
function readJsonFile() {
ensureStorageDir();
if (!fs.existsSync(STORAGE_FILE)) {
return null;
}
try {
const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed;
} catch (error) {
console.error('Failed to read GitHub auth file:', error);
return null;
}
}
function writeJsonFile(payload) {
ensureStorageDir();
fs.writeFileSync(STORAGE_FILE, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(STORAGE_FILE, 0o600);
} catch {
// best-effort
}
}
export function getGitHubAuth() {
const data = readJsonFile();
if (!data) {
return null;
}
const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
if (!accessToken) {
return null;
}
return {
accessToken,
scope: typeof data.scope === 'string' ? data.scope : '',
tokenType: typeof data.tokenType === 'string' ? data.tokenType : 'bearer',
createdAt: typeof data.createdAt === 'number' ? data.createdAt : null,
user: data.user && typeof data.user === 'object'
? {
login: typeof data.user.login === 'string' ? data.user.login : null,
avatarUrl: typeof data.user.avatarUrl === 'string' ? data.user.avatarUrl : null,
id: typeof data.user.id === 'number' ? data.user.id : null,
name: typeof data.user.name === 'string' ? data.user.name : null,
email: typeof data.user.email === 'string' ? data.user.email : null,
}
: null,
};
}
export function setGitHubAuth({ accessToken, scope, tokenType, user }) {
if (!accessToken || typeof accessToken !== 'string') {
throw new Error('accessToken is required');
}
writeJsonFile({
accessToken,
scope: typeof scope === 'string' ? scope : '',
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
createdAt: Date.now(),
user: user && typeof user === 'object'
? {
login: typeof user.login === 'string' ? user.login : undefined,
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
id: typeof user.id === 'number' ? user.id : undefined,
name: typeof user.name === 'string' ? user.name : undefined,
email: typeof user.email === 'string' ? user.email : undefined,
}
: undefined,
});
}
export function clearGitHubAuth() {
try {
if (fs.existsSync(STORAGE_FILE)) {
fs.unlinkSync(STORAGE_FILE);
}
return true;
} catch (error) {
console.error('Failed to clear GitHub auth file:', error);
return false;
}
}
export function getGitHubClientId() {
const raw = process.env.OPENCHAMBER_GITHUB_CLIENT_ID;
const clientId = typeof raw === 'string' ? raw.trim() : '';
if (clientId) return clientId;
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
const stored = typeof parsed?.githubClientId === 'string' ? parsed.githubClientId.trim() : '';
if (stored) return stored;
}
} catch {
// ignore
}
return DEFAULT_GITHUB_CLIENT_ID;
}
export function getGitHubScopes() {
const raw = process.env.OPENCHAMBER_GITHUB_SCOPES;
const fromEnv = typeof raw === 'string' ? raw.trim() : '';
if (fromEnv) return fromEnv;
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
const stored = typeof parsed?.githubScopes === 'string' ? parsed.githubScopes.trim() : '';
if (stored) return stored;
}
} catch {
// ignore
}
return DEFAULT_GITHUB_SCOPES;
}
export const GITHUB_AUTH_FILE = STORAGE_FILE;
@@ -0,0 +1,50 @@
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
const encodeForm = (params) => {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
body.set(key, String(value));
}
return body.toString();
};
async function postForm(url, params) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: encodeForm(params),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error_description || payload?.error || response.statusText;
const error = new Error(message || 'GitHub request failed');
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
export async function startDeviceFlow({ clientId, scope }) {
return postForm(DEVICE_CODE_URL, {
client_id: clientId,
scope,
});
}
export async function exchangeDeviceCode({ clientId, deviceCode }) {
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
const payload = await postForm(ACCESS_TOKEN_URL, {
client_id: clientId,
device_code: deviceCode,
grant_type: DEVICE_GRANT_TYPE,
});
return payload;
}
+10
View File
@@ -0,0 +1,10 @@
import { Octokit } from '@octokit/rest';
import { getGitHubAuth } from './github-auth.js';
export function getOctokitOrNull() {
const auth = getGitHubAuth();
if (!auth?.accessToken) {
return null;
}
return new Octokit({ auth: auth.accessToken });
}
+55
View File
@@ -0,0 +1,55 @@
import { getRemoteUrl } from './git-service.js';
export const parseGitHubRemoteUrl = (raw) => {
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (!value) {
return null;
}
// git@github.com:OWNER/REPO.git
if (value.startsWith('git@github.com:')) {
const rest = value.slice('git@github.com:'.length);
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
}
// ssh://git@github.com/OWNER/REPO.git
if (value.startsWith('ssh://git@github.com/')) {
const rest = value.slice('ssh://git@github.com/'.length);
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
}
// https://github.com/OWNER/REPO(.git)
try {
const url = new URL(value);
if (url.hostname !== 'github.com') {
return null;
}
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return null;
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
} catch {
return null;
}
};
export async function resolveGitHubRepoFromDirectory(directory) {
const remoteUrl = await getRemoteUrl(directory).catch(() => null);
if (!remoteUrl) {
return { repo: null, remoteUrl: null };
}
return {
repo: parseGitHubRemoteUrl(remoteUrl),
remoteUrl,
};
}
+1
View File
@@ -16,6 +16,7 @@ export const createWebGitAPI = (): GitAPI => ({
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'],
generateCommitMessage: gitApiHttp.generateCommitMessage,
generatePullRequestDescription: gitApiHttp.generatePullRequestDescription,
listGitWorktrees: gitApiHttp.listGitWorktrees,
addGitWorktree: gitApiHttp.addGitWorktree as GitAPI['addGitWorktree'],
removeGitWorktree: gitApiHttp.removeGitWorktree as GitAPI['removeGitWorktree'],
+124
View File
@@ -0,0 +1,124 @@
import type {
GitHubAPI,
GitHubAuthStatus,
GitHubPullRequest,
GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput,
GitHubPullRequestMergeResult,
GitHubPullRequestReadyInput,
GitHubPullRequestReadyResult,
GitHubPullRequestStatus,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubUserSummary,
} from '@openchamber/ui/lib/api/types';
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
return (await response.json().catch(() => null)) as T | null;
};
export const createWebGitHubAPI = (): GitHubAPI => ({
async authStatus(): Promise<GitHubAuthStatus> {
const response = await fetch('/api/github/auth/status', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status');
}
return payload;
},
async authStart(): Promise<GitHubDeviceFlowStart> {
const response = await fetch('/api/github/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({}),
});
const payload = await jsonOrNull<GitHubDeviceFlowStart & { error?: string }>(response);
if (!response.ok || !payload || !('deviceCode' in payload)) {
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to start GitHub auth');
}
return payload;
},
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
const response = await fetch('/api/github/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ deviceCode }),
});
const payload = await jsonOrNull<GitHubDeviceFlowComplete & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to complete GitHub auth');
}
return payload;
},
async authDisconnect(): Promise<{ removed: boolean }> {
const response = await fetch('/api/github/auth', { method: 'DELETE', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<{ removed?: boolean; error?: string }>(response);
if (!response.ok) {
throw new Error(payload?.error || response.statusText || 'Failed to disconnect GitHub');
}
return { removed: Boolean(payload?.removed) };
},
async me(): Promise<GitHubUserSummary> {
const response = await fetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to fetch GitHub user');
}
return payload;
},
async prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus> {
const response = await fetch(
`/api/github/pr/status?directory=${encodeURIComponent(directory)}&branch=${encodeURIComponent(branch)}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubPullRequestStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load PR status');
}
return payload;
},
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
const response = await fetch('/api/github/pr/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to create PR');
}
return body;
},
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
const response = await fetch('/api/github/pr/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequestMergeResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to merge PR');
}
return body;
},
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
const response = await fetch('/api/github/pr/ready', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequestReadyResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to mark PR ready');
}
return body;
},
});
+2
View File
@@ -7,6 +7,7 @@ import { createWebPermissionsAPI } from './permissions';
import { createWebNotificationsAPI } from './notifications';
import { createWebToolsAPI } from './tools';
import { createWebPushAPI } from './push';
import { createWebGitHubAPI } from './github';
export const createWebAPIs = (): RuntimeAPIs => ({
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
@@ -16,6 +17,7 @@ export const createWebAPIs = (): RuntimeAPIs => ({
settings: createWebSettingsAPI(),
permissions: createWebPermissionsAPI(),
notifications: createWebNotificationsAPI(),
github: createWebGitHubAPI(),
push: createWebPushAPI(),
tools: createWebToolsAPI(),
});