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

* feat: integrate GitHub OAuth device flow across runtimes

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

* feat: add GitHub PR status and PR description generation

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

* feat: add GitHub PR ready for review

Add API to mark pull requests as ready for review
Show a Ready button for draft PRs and reflect status in UI
Handle token expiration and GraphQL errors when marking ready
This commit is contained in:
Bohdan Triapitsyn
2026-01-23 16:08:58 +02:00
committed by GitHub
parent 0e715be7d6
commit 463e9ec4e3
43 changed files with 4999 additions and 106 deletions
@@ -267,7 +267,6 @@ async fn handle_question_asked(
properties: &Value,
notified_questions: &Mutex<HashSet<String>>,
) {
let session_id = properties.get("sessionID").and_then(Value::as_str);
let question_id = properties.get("id").and_then(Value::as_str);
@@ -234,13 +234,11 @@ pub async fn list_directory(
.output();
match output {
Ok(out) => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
Ok(out) => String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
Err(_) => HashSet::new(),
}
})
@@ -507,9 +505,13 @@ pub async fn delete_path(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_delete_message())?;
let resolved_path = resolve_sandboxed_path(
Some(trimmed.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|err| err.to_delete_message())?;
let metadata = fs::metadata(&resolved_path)
.await
@@ -544,9 +546,13 @@ pub async fn rename_path(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_old = resolve_sandboxed_path(Some(trimmed_old.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_rename_message())?;
let resolved_old = resolve_sandboxed_path(
Some(trimmed_old.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|err| err.to_rename_message())?;
let resolved_new = resolve_creatable_path(trimmed_new, &workspace_roots, default_root.as_ref())
.await
.map_err(|err| err.to_rename_message())?;
@@ -895,9 +901,13 @@ pub async fn read_file(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "File not found or access denied".to_string())?;
let resolved_path = resolve_sandboxed_path(
Some(trimmed.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|_| "File not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_path)
.await
@@ -932,9 +942,13 @@ pub async fn read_file_binary(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "File not found or access denied".to_string())?;
let resolved_path = resolve_sandboxed_path(
Some(trimmed.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|_| "File not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_path)
.await
@@ -1041,14 +1055,8 @@ fn build_shell_path_command(shell: &str) -> Vec<String> {
"-lic".to_string(),
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(),
],
"fish" => vec![
"-lic".to_string(),
"echo \"__PATH__=$PATH\"".to_string(),
],
_ => vec![
"-lic".to_string(),
"echo \"__PATH__=$PATH\"".to_string(),
],
"fish" => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()],
_ => vec!["-lic".to_string(), "echo \"__PATH__=$PATH\"".to_string()],
}
}
@@ -1130,9 +1138,13 @@ pub async fn exec_commands(
}
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
let resolved_cwd = resolve_sandboxed_path(Some(cwd_trimmed.to_string()), &workspace_roots, default_root.as_ref())
.await
.map_err(|_| "Working directory not found or access denied".to_string())?;
let resolved_cwd = resolve_sandboxed_path(
Some(cwd_trimmed.to_string()),
&workspace_roots,
default_root.as_ref(),
)
.await
.map_err(|_| "Working directory not found or access denied".to_string())?;
let metadata = fs::metadata(&resolved_cwd)
.await
+139 -2
View File
@@ -2042,7 +2042,9 @@ pub async fn get_remote_url(
.map_err(|e| e.to_string())?;
let remote_name = remote.unwrap_or_else(|| "origin".to_string());
let url = run_git(&["remote", "get-url", &remote_name], &root).await.ok();
let url = run_git(&["remote", "get-url", &remote_name], &root)
.await
.ok();
Ok(url.filter(|s| !s.is_empty()))
}
@@ -2162,7 +2164,11 @@ pub async fn set_git_identity(
.await
.map_err(|e| e.to_string())?;
}
let _ = run_git(&["config", "--local", "--unset", "credential.helper"], &root).await;
let _ = run_git(
&["config", "--local", "--unset", "credential.helper"],
&root,
)
.await;
} else if auth_type == "token" && profile.host.is_some() {
run_git(&["config", "--local", "credential.helper", "store"], &root)
.await
@@ -2330,3 +2336,134 @@ Diff summary:
last_error.unwrap_or_else(|| "unknown error".to_string())
))
}
#[tauri::command]
pub async fn generate_pr_description(
directory: String,
base: String,
head: String,
state: State<'_, DesktopRuntime>,
) -> Result<serde_json::Value, String> {
let root = validate_git_path(&directory, state.settings())
.await
.map_err(|e| e.to_string())?;
if base.trim().is_empty() || head.trim().is_empty() {
return Err("base and head are required".to_string());
}
// 1. Collect PR range diffs (base...head)
let range = format!("{}...{}", base.trim(), head.trim());
let files = {
let args = vec!["diff", "--name-only", range.as_str()];
let raw = run_git(&args, &root).await.unwrap_or_default();
raw.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect::<Vec<String>>()
};
if files.is_empty() {
return Err("No diffs available for base...head".to_string());
}
let mut diff_summaries = String::new();
for file in files.iter() {
let context = "-U3";
let args = vec![
"diff",
"--no-color",
context,
range.as_str(),
"--",
file.as_str(),
];
if let Ok(diff) = run_git(&args, &root).await {
if !diff.trim().is_empty() {
diff_summaries.push_str(&format!("FILE: {}\n{}\n\n", file, diff));
}
}
}
if diff_summaries.is_empty() {
return Err("No diffs available for selected files".to_string());
}
// 2. Construct PR-specific prompt
let prompt = format!(
r#"You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {{\"title\": string, \"body\": string}} (ONLY JSON in response, no markdown fences) with these rules:
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no \"feat:\", \"fix:\")
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
- Testing: bullet list (\"- Not tested\" allowed)
- Notes: bullet list; include breaking/rollout notes only when relevant
Context:
- base branch: {base}
- head branch: {head}
Diff summary:
{diffs}"#,
base = base.trim(),
head = head.trim(),
diffs = diff_summaries
);
let model = "gpt-5-nano";
// 3. Call API
let client = Client::new();
let res = client
.post("https://opencode.ai/zen/v1/responses")
.json(&serde_json::json!({
"model": model,
"input": [{ "role": "user", "content": prompt }],
"max_output_tokens": 1200,
"stream": false,
"reasoning": { "effort": "low" }
}))
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("API request failed: {}", res.status()));
}
let body_json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
let raw_content = body_json["output"]
.as_array()
.and_then(|items| items.iter().find(|item| item["type"] == "message"))
.and_then(|item| item["content"].as_array())
.and_then(|content| content.iter().find(|entry| entry["type"] == "output_text"))
.and_then(|entry| entry["text"].as_str())
.unwrap_or("")
.trim();
if raw_content.is_empty() {
return Err("No PR description returned by generator".to_string());
}
let cleaned = raw_content
.trim_start_matches("```json")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
let extracted = extract_json_object(cleaned);
let candidates = [
Some(cleaned.to_string()),
extracted,
Some(raw_content.to_string()),
];
for candidate in candidates.iter().flatten() {
if !(candidate.starts_with('{') || candidate.starts_with('[')) {
continue;
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(candidate) {
let title = parsed.get("title").and_then(|v| v.as_str()).unwrap_or("");
let body = parsed.get("body").and_then(|v| v.as_str()).unwrap_or("");
return Ok(serde_json::json!({ "title": title, "body": body }));
}
}
Ok(serde_json::json!({ "title": "", "body": raw_content }))
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
pub mod files;
pub mod git;
pub mod github;
pub mod logs;
pub mod notifications;
pub mod permissions;
@@ -129,7 +129,10 @@ pub async fn process_directory_selection(
if let Some(obj) = settings.as_object_mut() {
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
obj.insert("lastDirectory".to_string(), json!(normalized_path_for_update));
obj.insert(
"lastDirectory".to_string(),
json!(normalized_path_for_update),
);
}
(settings, project_id)
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashSet;
use tauri::State;
@@ -233,6 +233,20 @@ fn sanitize_settings_update(payload: &Value) -> Value {
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
}
}
// GitHub OAuth config (non-secret)
if let Some(Value::String(s)) = obj.get("githubClientId") {
let trimmed = s.trim();
if !trimmed.is_empty() {
result_obj.insert("githubClientId".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("githubScopes") {
let trimmed = s.trim();
if !trimmed.is_empty() {
result_obj.insert("githubScopes".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("defaultModel") {
let trimmed = s.trim();
if trimmed.is_empty() {
@@ -304,7 +318,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(10).min(500);
@@ -314,7 +331,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(20).min(500);
@@ -324,7 +344,10 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(30).min(1000);
+55 -14
View File
@@ -28,18 +28,26 @@ use axum::{
routing::{any, get, post},
Json, Router,
};
use commands::files::{create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary, rename_path, search_files, write_file};
use commands::files::{
create_directory, delete_path, exec_commands, list_directory, read_file, read_file_binary,
rename_path, search_files, write_file,
};
use commands::git::{
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch,
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit,
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message, get_commit_files,
get_current_git_identity, has_local_identity, get_git_branches, get_git_diff, get_git_file_diff,
discover_git_credentials, ensure_openchamber_ignored, generate_commit_message,
get_commit_files, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff,
get_git_identities, get_git_log, get_git_status, get_global_git_identity, get_remote_url,
git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree,
revert_git_file, set_git_identity, update_git_identity,
git_fetch, git_pull, git_push, has_local_identity, is_linked_worktree, list_git_worktrees,
remove_git_worktree, rename_branch, revert_git_file, set_git_identity, update_git_identity,
generate_pr_description,
};
use commands::logs::fetch_desktop_logs;
use commands::github::{
github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
github_pr_create, github_pr_merge, github_pr_ready, github_pr_status,
};
use commands::notifications::desktop_notify;
use commands::permissions::{
pick_directory, process_directory_selection, request_directory_access,
@@ -880,6 +888,7 @@ fn main() {
set_git_identity,
discover_git_credentials,
generate_commit_message,
generate_pr_description,
create_terminal_session,
send_terminal_input,
resize_terminal,
@@ -888,6 +897,15 @@ fn main() {
force_kill_terminal,
fetch_desktop_logs,
desktop_notify,
github_auth_status,
github_auth_start,
github_auth_complete,
github_auth_disconnect,
github_me,
github_pr_status,
github_pr_create,
github_pr_merge,
github_pr_ready,
])
.on_menu_event(|app, event| {
#[cfg(target_os = "macos")]
@@ -2256,7 +2274,9 @@ async fn handle_config_routes(
resolve_project_directory(&state, None).await.ok()
};
match opencode_config::get_provider_sources(trimmed, working_directory.as_deref()).await {
match opencode_config::get_provider_sources(trimmed, working_directory.as_deref())
.await
{
Ok(mut sources) => {
let auth = opencode_auth::get_provider_auth(trimmed).await;
sources.auth.exists = auth.ok().flatten().is_some();
@@ -2324,13 +2344,30 @@ async fn handle_config_routes(
let removal_result = if scope == "auth" {
opencode_auth::remove_provider_auth(trimmed).await
} else if scope == "user" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::User).await
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::User,
)
.await
} else if scope == "project" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Project).await
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::Project,
)
.await
} else if scope == "custom" {
opencode_config::remove_provider_config(trimmed, working_directory.as_deref(), opencode_config::ProviderScope::Custom).await
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::Custom,
)
.await
} else if scope == "all" {
let auth_removed = opencode_auth::remove_provider_auth(trimmed).await.unwrap_or(false);
let auth_removed = opencode_auth::remove_provider_auth(trimmed)
.await
.unwrap_or(false);
let user_removed = opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
@@ -2500,7 +2537,10 @@ async fn change_directory_handler(
"activeProjectId".to_string(),
Value::String(active_project_id.clone()),
);
map.insert("lastDirectory".to_string(), Value::String(path_value.clone()));
map.insert(
"lastDirectory".to_string(),
Value::String(path_value.clone()),
);
settings
})
@@ -2645,7 +2685,6 @@ impl SettingsStore {
}
}
pub(crate) async fn update_with<R, F>(&self, f: F) -> Result<(Value, R)>
where
F: FnOnce(Value) -> (Value, R),
@@ -2653,7 +2692,9 @@ impl SettingsStore {
let _lock = self.guard.lock().await;
let current = match fs::read(&self.path).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())),
Ok(bytes) => {
serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default()))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Value::Object(Default::default())
}
@@ -143,7 +143,10 @@ fn get_project_config_file(working_directory: &Path) -> PathBuf {
}
// Default to root opencode.json for new configs
candidates.into_iter().next().unwrap_or_else(|| working_directory.join("opencode.json"))
candidates
.into_iter()
.next()
.unwrap_or_else(|| working_directory.join("opencode.json"))
}
/// Get custom config file path from OPENCODE_CONFIG env var
@@ -390,15 +393,29 @@ pub async fn get_provider_sources(
.is_some();
Ok(ProviderSources {
auth: ProviderSourceInfo { exists: false, path: None },
user: ProviderSourceInfo { exists: user_exists, path: Some(layers.paths.user.to_string_lossy().to_string()) },
auth: ProviderSourceInfo {
exists: false,
path: None,
},
user: ProviderSourceInfo {
exists: user_exists,
path: Some(layers.paths.user.to_string_lossy().to_string()),
},
project: ProviderSourceInfo {
exists: project_exists,
path: layers.paths.project.as_ref().map(|p| p.to_string_lossy().to_string()),
path: layers
.paths
.project
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
},
custom: ProviderSourceInfo {
exists: custom_exists,
path: layers.paths.custom.as_ref().map(|p| p.to_string_lossy().to_string()),
path: layers
.paths
.custom
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
},
})
}
@@ -432,10 +449,7 @@ pub async fn remove_provider_config(
let mut remove_provider_key = false;
let mut remove_providers_key = false;
if let Some(provider_section) = config
.get_mut("provider")
.and_then(|v| v.as_object_mut())
{
if let Some(provider_section) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
if provider_section.remove(provider_id).is_some() {
removed = true;
if provider_section.is_empty() {
@@ -444,10 +458,7 @@ pub async fn remove_provider_config(
}
}
if let Some(provider_section) = config
.get_mut("providers")
.and_then(|v| v.as_object_mut())
{
if let Some(provider_section) = config.get_mut("providers").and_then(|v| v.as_object_mut()) {
if provider_section.remove(provider_id).is_some() {
removed = true;
if provider_section.is_empty() {
@@ -485,7 +496,8 @@ fn get_legacy_project_agent_dir(working_directory: &Path) -> PathBuf {
/// Get project-level agent path
fn get_project_agent_path(working_directory: &Path, agent_name: &str) -> PathBuf {
let plural_path = get_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
let legacy_path = get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
let legacy_path =
get_legacy_project_agent_dir(working_directory).join(format!("{}.md", agent_name));
if legacy_path.exists() && !plural_path.exists() {
return legacy_path;
}
@@ -566,8 +578,10 @@ fn get_legacy_project_command_dir(working_directory: &Path) -> PathBuf {
/// Get project-level command path
fn get_project_command_path(working_directory: &Path, command_name: &str) -> PathBuf {
let plural_path = get_project_command_dir(working_directory).join(format!("{}.md", command_name));
let legacy_path = get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name));
let plural_path =
get_project_command_dir(working_directory).join(format!("{}.md", command_name));
let legacy_path =
get_legacy_project_command_dir(working_directory).join(format!("{}.md", command_name));
if legacy_path.exists() && !plural_path.exists() {
return legacy_path;
}
@@ -791,7 +791,12 @@ async fn scan_clawdhub() -> Result<Vec<SkillsCatalogItem>> {
for _ in 0..max_pages {
let url = match &cursor {
Some(c) => format!("{}{}?cursor={}", CLAWDHUB_API_BASE, "/skills", urlencoding::encode(c)),
Some(c) => format!(
"{}{}?cursor={}",
CLAWDHUB_API_BASE,
"/skills",
urlencoding::encode(c)
),
None => format!("{}/skills", CLAWDHUB_API_BASE),
};
@@ -1440,7 +1445,11 @@ async fn install_skills_from_clawdhub(
// Check for conflicts first
let mut conflicts = vec![];
for sel in &req.selections {
let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir);
let slug = sel
.clawdhub
.as_ref()
.map(|c| c.slug.as_str())
.unwrap_or(&sel.skill_dir);
if !validate_skill_name(slug) {
continue;
}
@@ -1478,8 +1487,17 @@ async fn install_skills_from_clawdhub(
}
for sel in &req.selections {
let slug = sel.clawdhub.as_ref().map(|c| c.slug.as_str()).unwrap_or(&sel.skill_dir);
let mut version = sel.clawdhub.as_ref().map(|c| c.version.as_str()).unwrap_or("latest").to_string();
let slug = sel
.clawdhub
.as_ref()
.map(|c| c.slug.as_str())
.unwrap_or(&sel.skill_dir);
let mut version = sel
.clawdhub
.as_ref()
.map(|c| c.version.as_str())
.unwrap_or("latest")
.to_string();
if !validate_skill_name(slug) {
skipped.push(SkippedSkill {
@@ -1548,7 +1566,8 @@ async fn install_skills_from_clawdhub(
// Download and extract
match download_clawdhub_skill(slug, &version).await {
Ok(zip_data) => {
let temp_dir = std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4()));
let temp_dir =
std::env::temp_dir().join(format!("clawdhub-{}-{}", slug, Uuid::new_v4()));
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
// Extract ZIP using the zip crate