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:
committed by
GitHub
parent
0e715be7d6
commit
463e9ec4e3
Generated
+1
-1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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' });
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user