Files
openchamber/packages/desktop/src-tauri/src/commands/github.rs
T
2026-01-24 02:34:52 +02:00

2292 lines
68 KiB
Rust

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
use tauri::State;
use tokio::fs;
use tokio::process::Command;
use crate::DesktopRuntime;
const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
const API_USER_URL: &str = "https://api.github.com/user";
const API_EMAILS_URL: &str = "https://api.github.com/user/emails";
const API_PULLS_URL_PREFIX: &str = "https://api.github.com/repos";
const API_GRAPHQL_URL: &str = "https://api.github.com/graphql";
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
const DEFAULT_GITHUB_CLIENT_ID: &str = "Ov23liNd8TxDcMXtAHHM";
const DEFAULT_GITHUB_SCOPES: &str = "repo read:org workflow read:user user:email";
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubRepoRef {
owner: String,
repo: String,
url: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubChecksSummary {
state: String,
total: u64,
success: u64,
failure: u64,
pending: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestSummary {
number: u64,
title: String,
url: String,
state: String,
draft: bool,
base: String,
head: String,
#[serde(skip_serializing_if = "Option::is_none")]
head_sha: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
mergeable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
mergeable_state: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestHeadRepo {
owner: String,
repo: String,
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
clone_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestContextResult {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pr: Option<GitHubPullRequestContext>,
#[serde(skip_serializing_if = "Option::is_none")]
issue_comments: Option<Vec<GitHubIssueComment>>,
#[serde(skip_serializing_if = "Option::is_none")]
review_comments: Option<Vec<GitHubPullRequestReviewComment>>,
#[serde(skip_serializing_if = "Option::is_none")]
files: Option<Vec<GitHubPullRequestFile>>,
#[serde(skip_serializing_if = "Option::is_none")]
diff: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
checks: Option<GitHubChecksSummary>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestsListResult {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
prs: Option<Vec<GitHubPullRequestContext>>,
#[serde(skip_serializing_if = "Option::is_none")]
page: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
has_more: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestContext {
#[serde(flatten)]
summary: GitHubPullRequestSummary,
#[serde(skip_serializing_if = "Option::is_none")]
author: Option<GitHubUserSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
head_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
head_repo: Option<GitHubPullRequestHeadRepo>,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestFile {
filename: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
additions: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
deletions: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
changes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
patch: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestReviewComment {
id: u64,
url: String,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
author: Option<GitHubUserSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
line: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
position: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestStatus {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pr: Option<GitHubPullRequestSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
checks: Option<GitHubChecksSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
can_merge: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestMergeResult {
merged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubPullRequestReadyResult {
ready: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueLabel {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueSummary {
number: u64,
title: String,
url: String,
state: String,
#[serde(skip_serializing_if = "Option::is_none")]
author: Option<GitHubUserSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
labels: Option<Vec<GitHubIssueLabel>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssue {
#[serde(flatten)]
summary: GitHubIssueSummary,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
assignees: Option<Vec<GitHubUserSummary>>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueComment {
id: u64,
url: String,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
author: Option<GitHubUserSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssuesListResult {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
issues: Option<Vec<GitHubIssueSummary>>,
#[serde(skip_serializing_if = "Option::is_none")]
page: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
has_more: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueGetResult {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
issue: Option<GitHubIssue>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubIssueCommentsResult {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<GitHubRepoRef>,
#[serde(skip_serializing_if = "Option::is_none")]
comments: Option<Vec<GitHubIssueComment>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubUserSummary {
login: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubAuthStatus {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<GitHubUserSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubDeviceFlowStart {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubDeviceFlowCompleteSuccess {
connected: bool,
user: GitHubUserSummary,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubDeviceFlowCompletePending {
connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum GitHubDeviceFlowComplete {
Success(GitHubDeviceFlowCompleteSuccess),
Pending(GitHubDeviceFlowCompletePending),
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GitHubDisconnectResult {
removed: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct StoredAuth {
access_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
token_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<GitHubUserSummary>,
}
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
token_type: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
error_description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiUserResponse {
login: String,
id: u64,
#[serde(default)]
avatar_url: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct IssueUser {
login: String,
#[serde(default)]
id: Option<u64>,
#[serde(default)]
avatar_url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct IssueLabel {
name: String,
#[serde(default)]
color: Option<String>,
}
#[derive(Debug, Deserialize)]
struct IssueListItem {
number: u64,
title: String,
html_url: String,
state: String,
#[serde(default)]
user: Option<IssueUser>,
#[serde(default)]
labels: Vec<IssueLabel>,
#[serde(default)]
pull_request: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct IssueDetailsResponse {
number: u64,
title: String,
html_url: String,
state: String,
#[serde(default)]
user: Option<IssueUser>,
#[serde(default)]
labels: Vec<IssueLabel>,
#[serde(default)]
assignees: Vec<IssueUser>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
#[serde(default)]
pull_request: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct IssueCommentResponse {
id: u64,
html_url: String,
#[serde(default)]
body: Option<String>,
#[serde(default)]
user: Option<IssueUser>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PullFileResponse {
filename: String,
#[serde(default)]
status: Option<String>,
#[serde(default)]
additions: Option<u64>,
#[serde(default)]
deletions: Option<u64>,
#[serde(default)]
changes: Option<u64>,
#[serde(default)]
patch: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PullReviewCommentResponse {
id: u64,
html_url: String,
#[serde(default)]
body: Option<String>,
#[serde(default)]
user: Option<IssueUser>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
line: Option<i64>,
#[serde(default)]
position: Option<i64>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PrListItem {
number: u64,
}
#[derive(Debug, Deserialize)]
struct PullRef {
#[serde(rename = "ref")]
ref_name: String,
sha: String,
}
#[derive(Debug, Deserialize)]
struct PullBaseRef {
#[serde(rename = "ref")]
ref_name: String,
}
#[derive(Debug, Deserialize)]
struct PullDetailsResponse {
number: u64,
title: String,
html_url: String,
state: String,
#[serde(default)]
draft: bool,
#[serde(default)]
merged: bool,
#[serde(default)]
mergeable: Option<bool>,
#[serde(default)]
mergeable_state: Option<String>,
head: PullRef,
base: PullBaseRef,
#[serde(default)]
node_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CombinedStatusEntry {
state: String,
}
#[derive(Debug, Deserialize)]
struct CombinedStatusResponse {
#[serde(default)]
statuses: Vec<CombinedStatusEntry>,
}
#[derive(Debug, Deserialize)]
struct CheckRunEntry {
#[serde(default)]
status: Option<String>,
#[serde(default)]
conclusion: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CheckRunsResponse {
#[serde(default)]
check_runs: Vec<CheckRunEntry>,
}
#[derive(Debug, Deserialize)]
struct PermissionResponse {
permission: String,
}
#[derive(Debug, Serialize)]
struct PullCreateRequest<'a> {
title: &'a str,
head: &'a str,
base: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
draft: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct PullCreateResponse {
number: u64,
title: String,
html_url: String,
state: String,
#[serde(default)]
draft: bool,
head: PullRef,
base: PullBaseRef,
#[serde(default)]
mergeable: Option<bool>,
#[serde(default)]
mergeable_state: Option<String>,
}
#[derive(Debug, Serialize)]
struct PullMergeRequest<'a> {
merge_method: &'a str,
}
#[derive(Debug, Deserialize)]
struct PullMergeResponse {
merged: bool,
#[serde(default)]
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiEmailEntry {
email: String,
#[serde(default)]
primary: bool,
#[serde(default)]
verified: bool,
}
fn github_auth_path() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or_else(|| "No home directory".to_string())?;
let mut dir = home;
dir.push(".config");
dir.push("openchamber");
dir.push("github-auth.json");
Ok(dir)
}
async fn read_auth_file() -> Option<StoredAuth> {
let path = github_auth_path().ok()?;
let bytes = fs::read(&path).await.ok()?;
serde_json::from_slice::<StoredAuth>(&bytes).ok()
}
async fn write_auth_file(auth: &StoredAuth) -> Result<(), String> {
let path = github_auth_path()?;
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent).await;
}
let bytes = serde_json::to_vec_pretty(auth).map_err(|e| e.to_string())?;
fs::write(&path, bytes).await.map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(&path) {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
let _ = std::fs::set_permissions(&path, perms);
}
}
Ok(())
}
async fn clear_auth_file() -> bool {
let path = match github_auth_path() {
Ok(p) => p,
Err(_) => return false,
};
match fs::remove_file(&path).await {
Ok(_) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(_) => false,
}
}
fn read_string_setting(settings: &Value, key: &str) -> Option<String> {
settings
.get(key)?
.as_str()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
async fn resolve_client_config(state: &DesktopRuntime) -> (String, String) {
let settings = state
.settings()
.load()
.await
.unwrap_or(Value::Object(Default::default()));
let client_id = read_string_setting(&settings, "githubClientId")
.unwrap_or_else(|| DEFAULT_GITHUB_CLIENT_ID.to_string());
let scopes = read_string_setting(&settings, "githubScopes")
.unwrap_or_else(|| DEFAULT_GITHUB_SCOPES.to_string());
(client_id, scopes)
}
async fn fetch_primary_email(access_token: &str) -> Result<Option<String>, String> {
let client = reqwest::Client::new();
let resp = client
.get(API_EMAILS_URL)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "OpenChamber")
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("unauthorized".to_string());
}
if !resp.status().is_success() {
return Ok(None);
}
let list = resp
.json::<Vec<ApiEmailEntry>>()
.await
.map_err(|e| e.to_string())?;
let primary_verified = list
.iter()
.find(|e| e.primary && e.verified)
.map(|e| e.email.clone());
if primary_verified.is_some() {
return Ok(primary_verified);
}
let any_verified = list.iter().find(|e| e.verified).map(|e| e.email.clone());
Ok(any_verified)
}
async fn get_origin_remote_url(directory: &str) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(directory)
.arg("remote")
.arg("get-url")
.arg("origin")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn parse_github_remote_url(remote_url: &str) -> Option<GitHubRepoRef> {
let trimmed = remote_url.trim();
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
let cleaned = rest.trim_end_matches(".git");
let (owner, repo) = cleaned.split_once('/')?;
if owner.is_empty() || repo.is_empty() {
return None;
}
return Some(GitHubRepoRef {
owner: owner.to_string(),
repo: repo.to_string(),
url: format!("https://github.com/{}/{}", owner, repo),
});
}
if let Some(rest) = trimmed.strip_prefix("ssh://git@github.com/") {
let cleaned = rest.trim_end_matches(".git");
let (owner, repo) = cleaned.split_once('/')?;
if owner.is_empty() || repo.is_empty() {
return None;
}
return Some(GitHubRepoRef {
owner: owner.to_string(),
repo: repo.to_string(),
url: format!("https://github.com/{}/{}", owner, repo),
});
}
if let Ok(url) = url::Url::parse(trimmed) {
if url.host_str() != Some("github.com") {
return None;
}
let path = url.path().trim_matches('/').trim_end_matches(".git");
let (owner, repo) = path.split_once('/')?;
if owner.is_empty() || repo.is_empty() {
return None;
}
return Some(GitHubRepoRef {
owner: owner.to_string(),
repo: repo.to_string(),
url: format!("https://github.com/{}/{}", owner, repo),
});
}
None
}
async fn resolve_repo_from_directory(directory: &str) -> Option<GitHubRepoRef> {
let remote = get_origin_remote_url(directory).await?;
parse_github_remote_url(&remote)
}
async fn github_get_json<T: for<'de> Deserialize<'de>>(
url: &str,
access_token: &str,
) -> Result<T, String> {
let client = reqwest::Client::new();
let resp = client
.get(url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "OpenChamber")
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("unauthorized".to_string());
}
if !resp.status().is_success() {
return Err(format!("GitHub request failed: {}", resp.status()));
}
resp.json::<T>().await.map_err(|e| e.to_string())
}
async fn github_post_json<T: for<'de> Deserialize<'de>, B: Serialize>(
url: &str,
access_token: &str,
body: &B,
) -> Result<T, String> {
let client = reqwest::Client::new();
let resp = client
.post(url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "OpenChamber")
.json(body)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("unauthorized".to_string());
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("GitHub request failed: {} {}", status, text));
}
resp.json::<T>().await.map_err(|e| e.to_string())
}
async fn fetch_me(access_token: &str) -> Result<GitHubUserSummary, String> {
let client = reqwest::Client::new();
let resp = client
.get(API_USER_URL)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "OpenChamber")
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("unauthorized".to_string());
}
if !resp.status().is_success() {
return Err(format!("GitHub /user failed: {}", resp.status()));
}
let payload = resp
.json::<ApiUserResponse>()
.await
.map_err(|e| e.to_string())?;
let email = match payload.email.clone() {
Some(v) if !v.trim().is_empty() => Some(v),
_ => fetch_primary_email(access_token).await.ok().flatten(),
};
Ok(GitHubUserSummary {
login: payload.login,
id: Some(payload.id),
avatar_url: payload.avatar_url,
name: payload.name,
email,
})
}
fn map_issue_user(user: &IssueUser) -> GitHubUserSummary {
GitHubUserSummary {
login: user.login.clone(),
id: user.id,
avatar_url: user.avatar_url.clone(),
name: None,
email: None,
}
}
fn map_issue_labels(labels: Vec<IssueLabel>) -> Vec<GitHubIssueLabel> {
labels
.into_iter()
.filter(|l| !l.name.trim().is_empty())
.map(|l| GitHubIssueLabel {
name: l.name,
color: l.color,
})
.collect()
}
#[tauri::command]
pub async fn github_auth_status(
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubAuthStatus, String> {
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
});
}
match fetch_me(&stored.access_token).await {
Ok(user) => Ok(GitHubAuthStatus {
connected: true,
user: Some(user),
scope: stored.scope,
}),
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
})
}
Err(err) => Err(err),
}
}
#[tauri::command]
pub async fn github_auth_start(
state: State<'_, DesktopRuntime>,
) -> Result<GitHubDeviceFlowStart, String> {
let (client_id, scopes) = resolve_client_config(state.inner()).await;
let client = reqwest::Client::new();
let resp = client
.post(DEVICE_CODE_URL)
.header("Accept", "application/json")
.header("User-Agent", "OpenChamber")
.form(&[
("client_id", client_id.as_str()),
("scope", scopes.as_str()),
])
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub device code failed: {}", resp.status()));
}
let payload = resp
.json::<DeviceCodeResponse>()
.await
.map_err(|e| e.to_string())?;
Ok(GitHubDeviceFlowStart {
device_code: payload.device_code,
user_code: payload.user_code,
verification_uri: payload.verification_uri,
verification_uri_complete: payload.verification_uri_complete,
expires_in: payload.expires_in,
interval: payload.interval,
scope: Some(scopes),
})
}
#[tauri::command]
pub async fn github_auth_complete(
#[allow(non_snake_case)]
deviceCode: String,
state: State<'_, DesktopRuntime>,
) -> Result<GitHubDeviceFlowComplete, String> {
let device_code = deviceCode;
if device_code.trim().is_empty() {
return Err("deviceCode is required".to_string());
}
let (client_id, _) = resolve_client_config(state.inner()).await;
let client = reqwest::Client::new();
let resp = client
.post(ACCESS_TOKEN_URL)
.header("Accept", "application/json")
.header("User-Agent", "OpenChamber")
.form(&[
("client_id", client_id.as_str()),
("device_code", device_code.as_str()),
("grant_type", DEVICE_GRANT_TYPE),
])
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub token exchange failed: {}", resp.status()));
}
let payload = resp
.json::<TokenResponse>()
.await
.map_err(|e| e.to_string())?;
if let Some(error) = payload.error.clone() {
return Ok(GitHubDeviceFlowComplete::Pending(
GitHubDeviceFlowCompletePending {
connected: false,
status: Some(error.clone()),
error: Some(payload.error_description.unwrap_or(error)),
},
));
}
let access_token = payload.access_token.unwrap_or_default();
if access_token.trim().is_empty() {
return Err("Missing access_token from GitHub".to_string());
}
let user = fetch_me(&access_token).await.map_err(|e| {
if e == "unauthorized" {
"GitHub token invalid".to_string()
} else {
e
}
})?;
let stored = StoredAuth {
access_token: access_token.clone(),
scope: payload.scope.clone(),
token_type: payload.token_type.clone(),
created_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
),
user: Some(user.clone()),
};
write_auth_file(&stored).await?;
Ok(GitHubDeviceFlowComplete::Success(
GitHubDeviceFlowCompleteSuccess {
connected: true,
user,
scope: payload.scope,
},
))
}
#[tauri::command]
pub async fn github_auth_disconnect(
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubDisconnectResult, String> {
let removed = clear_auth_file().await;
Ok(GitHubDisconnectResult { removed })
}
#[tauri::command]
pub async fn github_me(_state: State<'_, DesktopRuntime>) -> Result<GitHubUserSummary, String> {
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Err("GitHub not connected".to_string());
};
match fetch_me(&stored.access_token).await {
Ok(user) => Ok(user),
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
Err("GitHub token expired or revoked".to_string())
}
Err(err) => Err(err),
}
}
#[tauri::command]
pub async fn github_pr_status(
directory: String,
branch: String,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubPullRequestStatus, String> {
let directory = directory.trim().to_string();
let branch = branch.trim().to_string();
if directory.is_empty() || branch.is_empty() {
return Err("directory and branch are required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubPullRequestStatus {
connected: false,
repo: None,
branch: Some(branch),
pr: None,
checks: None,
can_merge: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubPullRequestStatus {
connected: false,
repo: None,
branch: Some(branch),
pr: None,
checks: None,
can_merge: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubPullRequestStatus {
connected: true,
repo: None,
branch: Some(branch),
pr: None,
checks: None,
can_merge: Some(false),
});
};
let head = format!("{}:{}", repo.owner, branch);
let head_encoded = urlencoding::encode(&head);
let list_url = format!(
"{}/{}/{}/pulls?state=open&head={}&per_page=10",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, head_encoded
);
let list = github_get_json::<Vec<PrListItem>>(&list_url, &stored.access_token).await;
let list = match list {
Ok(v) => v,
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
return Ok(GitHubPullRequestStatus {
connected: false,
repo: None,
branch: Some(branch),
pr: None,
checks: None,
can_merge: None,
});
}
Err(err) => return Err(err),
};
let Some(first) = list.first() else {
return Ok(GitHubPullRequestStatus {
connected: true,
repo: Some(repo),
branch: Some(branch),
pr: None,
checks: None,
can_merge: Some(false),
});
};
let pr_url = format!(
"{}/{}/{}/pulls/{}",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, first.number
);
let pr = github_get_json::<PullDetailsResponse>(&pr_url, &stored.access_token).await?;
// Checks summary: prefer check-runs (Actions), fallback to classic statuses
let mut checks: Option<GitHubChecksSummary> = None;
let check_runs_url = format!(
"{}/{}/{}/commits/{}/check-runs",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, pr.head.sha
);
if let Ok(runs) = github_get_json::<CheckRunsResponse>(&check_runs_url, &stored.access_token).await {
if !runs.check_runs.is_empty() {
let mut success = 0;
let mut failure = 0;
let mut pending = 0;
for run in runs.check_runs.iter() {
let status = run.status.as_deref().unwrap_or("");
let conclusion = run.conclusion.as_deref().unwrap_or("");
if status == "queued" || status == "in_progress" {
pending += 1;
continue;
}
if conclusion.is_empty() {
pending += 1;
continue;
}
if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" {
success += 1;
} else {
failure += 1;
}
}
let total = success + failure + pending;
let state = if failure > 0 {
"failure"
} else if pending > 0 {
"pending"
} else if total > 0 {
"success"
} else {
"unknown"
};
checks = Some(GitHubChecksSummary {
state: state.to_string(),
total,
success,
failure,
pending,
});
}
}
if checks.is_none() {
let status_url = format!(
"{}/{}/{}/commits/{}/status",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, pr.head.sha
);
if let Ok(status) = github_get_json::<CombinedStatusResponse>(&status_url, &stored.access_token).await {
let mut success = 0;
let mut failure = 0;
let mut pending = 0;
for s in status.statuses.iter() {
match s.state.as_str() {
"success" => success += 1,
"failure" | "error" => failure += 1,
"pending" => pending += 1,
_ => {}
}
}
let total = success + failure + pending;
let state = if failure > 0 {
"failure"
} else if pending > 0 {
"pending"
} else if total > 0 {
"success"
} else {
"unknown"
};
checks = Some(GitHubChecksSummary {
state: state.to_string(),
total,
success,
failure,
pending,
});
}
}
// Permissions (best-effort)
let mut can_merge = None;
if let Some(user) = stored.user.as_ref() {
if !user.login.is_empty() {
let perm_url = format!(
"{}/{}/{}/collaborators/{}/permission",
API_PULLS_URL_PREFIX,
repo.owner,
repo.repo,
urlencoding::encode(&user.login)
);
if let Ok(perm) = github_get_json::<PermissionResponse>(&perm_url, &stored.access_token).await {
let p = perm.permission;
can_merge = Some(p == "admin" || p == "maintain" || p == "write");
}
}
}
let state = if pr.merged {
"merged"
} else if pr.state == "closed" {
"closed"
} else {
"open"
};
Ok(GitHubPullRequestStatus {
connected: true,
repo: Some(repo),
branch: Some(branch),
pr: Some(GitHubPullRequestSummary {
number: pr.number,
title: pr.title,
url: pr.html_url,
state: state.to_string(),
draft: pr.draft,
base: pr.base.ref_name,
head: pr.head.ref_name,
head_sha: Some(pr.head.sha),
mergeable: pr.mergeable,
mergeable_state: pr.mergeable_state,
}),
checks,
can_merge,
})
}
#[tauri::command]
pub async fn github_pr_create(
directory: String,
title: String,
head: String,
base: String,
body: Option<String>,
draft: Option<bool>,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubPullRequestSummary, String> {
let directory = directory.trim().to_string();
let title = title.trim().to_string();
let head = head.trim().to_string();
let base = base.trim().to_string();
if directory.is_empty() || title.is_empty() || head.is_empty() || base.is_empty() {
return Err("directory, title, head, base are required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Err("GitHub not connected".to_string());
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Err("GitHub not connected".to_string());
}
let repo = resolve_repo_from_directory(&directory)
.await
.ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?;
let url = format!("{}/{}/{}/pulls", API_PULLS_URL_PREFIX, repo.owner, repo.repo);
let request = PullCreateRequest {
title: &title,
head: &head,
base: &base,
body: body.as_deref(),
draft,
};
let created = github_post_json::<PullCreateResponse, _>(&url, &stored.access_token, &request).await?;
Ok(GitHubPullRequestSummary {
number: created.number,
title: created.title,
url: created.html_url,
state: if created.state == "closed" {
"closed".to_string()
} else {
"open".to_string()
},
draft: created.draft,
base: created.base.ref_name,
head: created.head.ref_name,
head_sha: Some(created.head.sha),
mergeable: created.mergeable,
mergeable_state: created.mergeable_state,
})
}
#[tauri::command]
pub async fn github_pr_merge(
directory: String,
number: u64,
method: String,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubPullRequestMergeResult, String> {
let directory = directory.trim().to_string();
let method = method.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
if number == 0 {
return Err("number is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Err("GitHub not connected".to_string());
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Err("GitHub not connected".to_string());
}
let repo = resolve_repo_from_directory(&directory)
.await
.ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?;
let url = format!(
"{}/{}/{}/pulls/{}/merge",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let merge_method = if method.is_empty() { "merge" } else { method.as_str() };
let request = PullMergeRequest { merge_method };
let client = reqwest::Client::new();
let resp = client
.put(url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {}", stored.access_token))
.header("User-Agent", "OpenChamber")
.json(&request)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = clear_auth_file().await;
return Err("GitHub token expired or revoked".to_string());
}
if resp.status() == reqwest::StatusCode::FORBIDDEN {
return Err("Not authorized to merge this PR".to_string());
}
if resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED
|| resp.status() == reqwest::StatusCode::CONFLICT
{
return Ok(GitHubPullRequestMergeResult {
merged: false,
message: Some("PR not mergeable".to_string()),
});
}
if !resp.status().is_success() {
return Err(format!("GitHub merge failed: {}", resp.status()));
}
let parsed = resp.json::<PullMergeResponse>().await.map_err(|e| e.to_string())?;
Ok(GitHubPullRequestMergeResult {
merged: parsed.merged,
message: parsed.message,
})
}
#[tauri::command]
pub async fn github_pr_ready(
directory: String,
number: u64,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubPullRequestReadyResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
if number == 0 {
return Err("number is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Err("GitHub not connected".to_string());
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Err("GitHub not connected".to_string());
}
let repo = resolve_repo_from_directory(&directory)
.await
.ok_or_else(|| "Unable to resolve GitHub repo from git remote".to_string())?;
let pr_url = format!(
"{}/{}/{}/pulls/{}",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let pr = github_get_json::<PullDetailsResponse>(&pr_url, &stored.access_token).await?;
let node_id = pr
.node_id
.ok_or_else(|| "Failed to resolve PR node id".to_string())?;
if !pr.draft {
return Ok(GitHubPullRequestReadyResult { ready: true });
}
let query = "mutation($pullRequestId: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { pullRequest { id isDraft } } }";
let payload = serde_json::json!({
"query": query,
"variables": { "pullRequestId": node_id }
});
let client = reqwest::Client::new();
let resp = client
.post(API_GRAPHQL_URL)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {}", stored.access_token))
.header("User-Agent", "OpenChamber")
.json(&payload)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = clear_auth_file().await;
return Err("GitHub token expired or revoked".to_string());
}
if resp.status() == reqwest::StatusCode::FORBIDDEN {
return Err("Not authorized to mark PR ready".to_string());
}
if !resp.status().is_success() {
return Err(format!("GitHub request failed: {}", resp.status()));
}
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if body.get("errors").is_some() {
return Err("GitHub GraphQL error".to_string());
}
Ok(GitHubPullRequestReadyResult { ready: true })
}
#[tauri::command]
pub async fn github_issues_list(
directory: String,
page: Option<u32>,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubIssuesListResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubIssuesListResult {
connected: false,
repo: None,
issues: None,
page: None,
has_more: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubIssuesListResult {
connected: false,
repo: None,
issues: None,
page: None,
has_more: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubIssuesListResult {
connected: true,
repo: None,
issues: Some(vec![]),
page: Some(page.unwrap_or(1).max(1) as u64),
has_more: Some(false),
});
};
let page = page.unwrap_or(1).max(1);
let url = format!(
"{}/{}/{}/issues?state=open&per_page=50&page={}",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, page
);
let resp = reqwest::Client::new()
.get(url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {}", stored.access_token))
.header("User-Agent", "OpenChamber")
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = clear_auth_file().await;
return Ok(GitHubIssuesListResult {
connected: false,
repo: None,
issues: None,
page: None,
has_more: None,
});
}
if !resp.status().is_success() {
return Err(format!("GitHub request failed: {}", resp.status()));
}
let link = resp
.headers()
.get("link")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let has_more = link.contains("rel=\"next\"");
let list = resp.json::<Vec<IssueListItem>>().await.map_err(|e| e.to_string())?;
let issues = list
.into_iter()
.filter(|item| item.pull_request.is_none())
.map(|item| GitHubIssueSummary {
number: item.number,
title: item.title,
url: item.html_url,
state: item.state,
author: item.user.as_ref().map(map_issue_user),
labels: Some(map_issue_labels(item.labels)),
})
.collect::<Vec<_>>();
Ok(GitHubIssuesListResult {
connected: true,
repo: Some(repo),
issues: Some(issues),
page: Some(page as u64),
has_more: Some(has_more),
})
}
#[tauri::command]
pub async fn github_issue_get(
directory: String,
number: u64,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubIssueGetResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
if number == 0 {
return Err("number is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubIssueGetResult {
connected: false,
repo: None,
issue: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubIssueGetResult {
connected: false,
repo: None,
issue: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubIssueGetResult {
connected: true,
repo: None,
issue: None,
});
};
let url = format!(
"{}/{}/{}/issues/{}",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let issue = github_get_json::<IssueDetailsResponse>(&url, &stored.access_token).await;
let issue = match issue {
Ok(v) => v,
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
return Ok(GitHubIssueGetResult {
connected: false,
repo: None,
issue: None,
});
}
Err(err) => return Err(err),
};
if issue.pull_request.is_some() {
return Err("Not a GitHub issue".to_string());
}
let summary = GitHubIssueSummary {
number: issue.number,
title: issue.title,
url: issue.html_url,
state: issue.state,
author: issue.user.as_ref().map(map_issue_user),
labels: Some(map_issue_labels(issue.labels)),
};
let assignees = issue
.assignees
.iter()
.map(map_issue_user)
.collect::<Vec<_>>();
Ok(GitHubIssueGetResult {
connected: true,
repo: Some(repo),
issue: Some(GitHubIssue {
summary,
body: issue.body,
assignees: Some(assignees),
created_at: issue.created_at,
updated_at: issue.updated_at,
}),
})
}
#[tauri::command]
pub async fn github_issue_comments(
directory: String,
number: u64,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubIssueCommentsResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
if number == 0 {
return Err("number is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubIssueCommentsResult {
connected: false,
repo: None,
comments: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubIssueCommentsResult {
connected: false,
repo: None,
comments: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubIssueCommentsResult {
connected: true,
repo: None,
comments: Some(vec![]),
});
};
let url = format!(
"{}/{}/{}/issues/{}/comments?per_page=100",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let comments = github_get_json::<Vec<IssueCommentResponse>>(&url, &stored.access_token).await;
let comments = match comments {
Ok(v) => v,
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
return Ok(GitHubIssueCommentsResult {
connected: false,
repo: None,
comments: None,
});
}
Err(err) => return Err(err),
};
let mapped = comments
.into_iter()
.map(|c| GitHubIssueComment {
id: c.id,
url: c.html_url,
body: c.body.unwrap_or_default(),
author: c.user.as_ref().map(map_issue_user),
created_at: c.created_at,
updated_at: c.updated_at,
})
.collect::<Vec<_>>();
Ok(GitHubIssueCommentsResult {
connected: true,
repo: Some(repo),
comments: Some(mapped),
})
}
fn read_string_field(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
value.get(key).and_then(|v| v.as_bool())
}
fn read_number_field(value: &Value, key: &str) -> Option<u64> {
value.get(key).and_then(|v| v.as_u64())
}
fn map_pr_user(value: &Value) -> Option<GitHubUserSummary> {
let login = value.get("login").and_then(|v| v.as_str()).unwrap_or("");
if login.trim().is_empty() {
return None;
}
Some(GitHubUserSummary {
login: login.to_string(),
id: value.get("id").and_then(|v| v.as_u64()),
avatar_url: value
.get("avatar_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
name: None,
email: None,
})
}
fn map_pr_head_repo(value: &Value) -> Option<GitHubPullRequestHeadRepo> {
let owner = value
.get("owner")
.and_then(|o| o.get("login"))
.and_then(|v| v.as_str())
.unwrap_or("");
let repo = value.get("name").and_then(|v| v.as_str()).unwrap_or("");
let url = value.get("html_url").and_then(|v| v.as_str()).unwrap_or("");
if owner.trim().is_empty() || repo.trim().is_empty() || url.trim().is_empty() {
return None;
}
Some(GitHubPullRequestHeadRepo {
owner: owner.to_string(),
repo: repo.to_string(),
url: url.to_string(),
clone_url: value
.get("clone_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
})
}
async fn github_get_text(url: &str, access_token: &str, accept: &str) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.get(url)
.header("Accept", accept)
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "OpenChamber")
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("unauthorized".to_string());
}
if !resp.status().is_success() {
return Err(format!("GitHub request failed: {}", resp.status()));
}
resp.text().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn github_prs_list(
directory: String,
page: Option<u32>,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubPullRequestsListResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubPullRequestsListResult {
connected: false,
repo: None,
prs: None,
page: None,
has_more: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubPullRequestsListResult {
connected: false,
repo: None,
prs: None,
page: None,
has_more: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubPullRequestsListResult {
connected: true,
repo: None,
prs: Some(vec![]),
page: Some(page.unwrap_or(1).max(1) as u64),
has_more: Some(false),
});
};
let page = page.unwrap_or(1).max(1);
let url = format!(
"{}/{}/{}/pulls?state=open&per_page=50&page={}",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, page
);
let resp = reqwest::Client::new()
.get(url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {}", stored.access_token))
.header("User-Agent", "OpenChamber")
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = clear_auth_file().await;
return Ok(GitHubPullRequestsListResult {
connected: false,
repo: None,
prs: None,
page: None,
has_more: None,
});
}
if !resp.status().is_success() {
return Err(format!("GitHub request failed: {}", resp.status()));
}
let link = resp
.headers()
.get("link")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let has_more = link.contains("rel=\"next\"");
let list = resp.json::<Vec<Value>>().await.map_err(|e| e.to_string())?;
let prs = list
.into_iter()
.filter_map(|pr| {
let number = read_number_field(&pr, "number")?;
let head = pr.get("head")?;
let base = pr.get("base")?;
let head_ref = read_string_field(head, "ref");
let base_ref = read_string_field(base, "ref");
let merged = read_bool_field(&pr, "merged").unwrap_or(false);
let state_raw = read_string_field(&pr, "state");
let state = if merged {
"merged".to_string()
} else if state_raw == "closed" {
"closed".to_string()
} else {
"open".to_string()
};
let head_sha = read_string_field(head, "sha");
let head_sha = if head_sha.trim().is_empty() { None } else { Some(head_sha) };
let mergeable = read_bool_field(&pr, "mergeable");
let mergeable_state = pr
.get("mergeable_state")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let author = pr.get("user").and_then(map_pr_user);
let head_label = head.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
let head_repo = head.get("repo").and_then(map_pr_head_repo);
Some(GitHubPullRequestContext {
summary: GitHubPullRequestSummary {
number,
title: read_string_field(&pr, "title"),
url: read_string_field(&pr, "html_url"),
state,
draft: read_bool_field(&pr, "draft").unwrap_or(false),
base: base_ref,
head: head_ref,
head_sha,
mergeable,
mergeable_state,
},
author,
head_label,
head_repo,
body: None,
created_at: None,
updated_at: None,
})
})
.collect::<Vec<_>>();
Ok(GitHubPullRequestsListResult {
connected: true,
repo: Some(repo),
prs: Some(prs),
page: Some(page as u64),
has_more: Some(has_more),
})
}
#[tauri::command]
pub async fn github_pr_context(
directory: String,
number: u64,
#[allow(non_snake_case)]
includeDiff: bool,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubPullRequestContextResult, String> {
let directory = directory.trim().to_string();
if directory.is_empty() {
return Err("directory is required".to_string());
}
if number == 0 {
return Err("number is required".to_string());
}
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubPullRequestContextResult {
connected: false,
repo: None,
pr: None,
issue_comments: None,
review_comments: None,
files: None,
diff: None,
checks: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubPullRequestContextResult {
connected: false,
repo: None,
pr: None,
issue_comments: None,
review_comments: None,
files: None,
diff: None,
checks: None,
});
}
let repo = resolve_repo_from_directory(&directory).await;
let Some(repo) = repo else {
return Ok(GitHubPullRequestContextResult {
connected: true,
repo: None,
pr: None,
issue_comments: None,
review_comments: None,
files: None,
diff: None,
checks: None,
});
};
let pr_url = format!("{}/{}/{}/pulls/{}", API_PULLS_URL_PREFIX, repo.owner, repo.repo, number);
let pr_json = github_get_json::<Value>(&pr_url, &stored.access_token).await;
let pr_json = match pr_json {
Ok(v) => v,
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
return Ok(GitHubPullRequestContextResult {
connected: false,
repo: None,
pr: None,
issue_comments: None,
review_comments: None,
files: None,
diff: None,
checks: None,
});
}
Err(err) => return Err(err),
};
let head = pr_json.get("head").cloned().unwrap_or(Value::Null);
let base = pr_json.get("base").cloned().unwrap_or(Value::Null);
let head_ref = read_string_field(&head, "ref");
let base_ref = read_string_field(&base, "ref");
let merged = read_bool_field(&pr_json, "merged").unwrap_or(false);
let state_raw = read_string_field(&pr_json, "state");
let state = if merged {
"merged".to_string()
} else if state_raw == "closed" {
"closed".to_string()
} else {
"open".to_string()
};
let head_sha = read_string_field(&head, "sha");
let head_sha = if head_sha.trim().is_empty() { None } else { Some(head_sha) };
let pr = GitHubPullRequestContext {
summary: GitHubPullRequestSummary {
number,
title: read_string_field(&pr_json, "title"),
url: read_string_field(&pr_json, "html_url"),
state,
draft: read_bool_field(&pr_json, "draft").unwrap_or(false),
base: base_ref,
head: head_ref,
head_sha,
mergeable: read_bool_field(&pr_json, "mergeable"),
mergeable_state: pr_json
.get("mergeable_state")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
},
author: pr_json.get("user").and_then(map_pr_user),
head_label: head.get("label").and_then(|v| v.as_str()).map(|s| s.to_string()),
head_repo: head.get("repo").and_then(map_pr_head_repo),
body: pr_json.get("body").and_then(|v| v.as_str()).map(|s| s.to_string()),
created_at: pr_json.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()),
updated_at: pr_json.get("updated_at").and_then(|v| v.as_str()).map(|s| s.to_string()),
};
let issue_comments_url = format!(
"{}/{}/{}/issues/{}/comments?per_page=100",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let issue_comments = github_get_json::<Vec<IssueCommentResponse>>(&issue_comments_url, &stored.access_token).await?;
let issue_comments = issue_comments
.into_iter()
.map(|c| GitHubIssueComment {
id: c.id,
url: c.html_url,
body: c.body.unwrap_or_default(),
author: c.user.as_ref().map(map_issue_user),
created_at: c.created_at,
updated_at: c.updated_at,
})
.collect::<Vec<_>>();
let review_comments_url = format!(
"{}/{}/{}/pulls/{}/comments?per_page=100",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let review_comments = github_get_json::<Vec<PullReviewCommentResponse>>(&review_comments_url, &stored.access_token).await?;
let review_comments = review_comments
.into_iter()
.map(|c| GitHubPullRequestReviewComment {
id: c.id,
url: c.html_url,
body: c.body.unwrap_or_default(),
author: c.user.as_ref().map(map_issue_user),
path: c.path,
line: c.line,
position: c.position,
created_at: c.created_at,
updated_at: c.updated_at,
})
.collect::<Vec<_>>();
let files_url = format!(
"{}/{}/{}/pulls/{}/files?per_page=100",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, number
);
let files = github_get_json::<Vec<PullFileResponse>>(&files_url, &stored.access_token).await?;
let files = files
.into_iter()
.map(|f| GitHubPullRequestFile {
filename: f.filename,
status: f.status,
additions: f.additions,
deletions: f.deletions,
changes: f.changes,
patch: f.patch,
})
.collect::<Vec<_>>();
// checks summary (same as github_pr_status)
let mut checks: Option<GitHubChecksSummary> = None;
if let Some(ref sha) = pr.summary.head_sha {
let check_runs_url = format!(
"{}/{}/{}/commits/{}/check-runs",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, sha
);
if let Ok(runs) = github_get_json::<CheckRunsResponse>(&check_runs_url, &stored.access_token).await {
if !runs.check_runs.is_empty() {
let mut success = 0;
let mut failure = 0;
let mut pending = 0;
for run in runs.check_runs.iter() {
let status = run.status.as_deref().unwrap_or("");
let conclusion = run.conclusion.as_deref().unwrap_or("");
if status == "queued" || status == "in_progress" {
pending += 1;
continue;
}
if conclusion.is_empty() {
pending += 1;
continue;
}
if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" {
success += 1;
} else {
failure += 1;
}
}
let total = success + failure + pending;
let state = if failure > 0 {
"failure"
} else if pending > 0 {
"pending"
} else if total > 0 {
"success"
} else {
"unknown"
};
checks = Some(GitHubChecksSummary {
state: state.to_string(),
total,
success,
failure,
pending,
});
}
}
if checks.is_none() {
let status_url = format!(
"{}/{}/{}/commits/{}/status",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, sha
);
if let Ok(status) = github_get_json::<CombinedStatusResponse>(&status_url, &stored.access_token).await {
let mut success = 0;
let mut failure = 0;
let mut pending = 0;
for s in status.statuses.iter() {
match s.state.as_str() {
"success" => success += 1,
"failure" | "error" => failure += 1,
"pending" => pending += 1,
_ => {}
}
}
let total = success + failure + pending;
let state = if failure > 0 {
"failure"
} else if pending > 0 {
"pending"
} else if total > 0 {
"success"
} else {
"unknown"
};
checks = Some(GitHubChecksSummary {
state: state.to_string(),
total,
success,
failure,
pending,
});
}
}
}
let diff = if includeDiff {
let diff_text = github_get_text(&pr_url, &stored.access_token, "application/vnd.github.v3.diff").await;
match diff_text {
Ok(v) => Some(v),
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
return Ok(GitHubPullRequestContextResult {
connected: false,
repo: None,
pr: None,
issue_comments: None,
review_comments: None,
files: None,
diff: None,
checks: None,
});
}
Err(_) => None,
}
} else {
None
};
Ok(GitHubPullRequestContextResult {
connected: true,
repo: Some(repo),
pr: Some(pr),
issue_comments: Some(issue_comments),
review_comments: Some(review_comments),
files: Some(files),
diff,
checks,
})
}