Skip to main content

qualia_client_core/
github.rs

1use reqwest::blocking::Client;
2use serde_json::{json, Value};
3use std::collections::HashMap;
4use std::time::Duration;
5
6const GITHUB_API_URL: &str = "https://api.github.com";
7const USER_AGENT: &str = "qualia-webizen-client";
8
9/// Creates a reqwest client configured for GitHub API.
10fn github_client(token: &str) -> Result<Client, String> {
11    let mut headers = reqwest::header::HeaderMap::new();
12    headers.insert(
13        reqwest::header::AUTHORIZATION,
14        reqwest::header::HeaderValue::from_str(&format!("token {}", token))
15            .map_err(|e| format!("Invalid token header: {}", e))?,
16    );
17    headers.insert(
18        reqwest::header::ACCEPT,
19        reqwest::header::HeaderValue::from_static("application/vnd.github.v3+json"),
20    );
21    headers.insert(
22        reqwest::header::USER_AGENT,
23        reqwest::header::HeaderValue::from_static(USER_AGENT),
24    );
25
26    let client = Client::builder()
27        .default_headers(headers)
28        .timeout(Duration::from_secs(30))
29        .build()
30        .map_err(|e| format!("Failed to build client: {}", e))?;
31
32    Ok(client)
33}
34
35/// Helper to parse GitHub API errors
36fn handle_response(resp: reqwest::blocking::Response) -> Result<Value, String> {
37    let status = resp.status();
38    let text = resp
39        .text()
40        .map_err(|e| format!("Failed to read response: {}", e))?;
41    if !status.is_success() {
42        return Err(format!("GitHub API error ({}): {}", status, text));
43    }
44    serde_json::from_str(&text).map_err(|e| format!("Failed to parse GitHub JSON: {e}"))
45}
46
47/// Verifies a GitHub Personal Access Token (PAT).
48pub fn verify_github_token(token: &str) -> Result<String, String> {
49    let client = github_client(token)?;
50    let resp = client
51        .get(&format!("{}/user", GITHUB_API_URL))
52        .send()
53        .map_err(|e| format!("Failed to send request: {}", e))?;
54    let json = handle_response(resp)?;
55    let login = json["login"].as_str().unwrap_or("unknown").to_string();
56    Ok(login)
57}
58
59/// Creates a new public GitHub repository. Returns the repository full name (e.g., "username/repo").
60pub fn create_repository(token: &str, name: &str) -> Result<String, String> {
61    let client = github_client(token)?;
62
63    // First, verify the user to get their login so we can return the full name if auto_init takes time.
64    let _login = verify_github_token(token)?;
65
66    // Create the repo
67    let resp = client
68        .post(&format!("{}/user/repos", GITHUB_API_URL))
69        .json(&json!({
70            "name": name,
71            "description": "Generated by Qualia Webizen",
72            "private": false,
73            "auto_init": true, // Automatically creates the main branch with a README
74        }))
75        .send()
76        .map_err(|e| format!("Request failed: {}", e))?;
77
78    let status = resp.status();
79    let text = resp
80        .text()
81        .map_err(|e| format!("Failed to read text: {}", e))?;
82
83    // 422 usually means the repository already exists
84    if status.as_u16() == 422 && text.contains("already exists") {
85        return Ok(format!("{}/{}", _login, name));
86    }
87
88    if !status.is_success() {
89        return Err(format!(
90            "GitHub API error creating repo ({}): {}",
91            status, text
92        ));
93    }
94
95    let result_json: Value =
96        serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {}", e))?;
97    let full_name = result_json["full_name"]
98        .as_str()
99        .ok_or_else(|| "No full_name in response".to_string())?;
100    Ok(full_name.to_string())
101}
102
103/// Pushes a directory of files to a GitHub repository using the Git Data API.
104/// `files` is a map of file path (e.g. "index.html") to its string content.
105pub fn push_static_site(
106    token: &str,
107    full_name: &str,
108    files: HashMap<String, String>,
109) -> Result<(), String> {
110    if files.is_empty() {
111        return Ok(());
112    }
113
114    let client = github_client(token)?;
115    let base_url = format!("{}/repos/{}", GITHUB_API_URL, full_name);
116
117    // 1. Get the current commit SHA for the default branch (usually 'main' or 'master')
118    let ref_resp = client
119        .get(&format!("{}/git/ref/heads/main", base_url))
120        .send()
121        .map_err(|e| format!("Req failed: {}", e))?;
122    let ref_json = if ref_resp.status().is_success() {
123        handle_response(ref_resp)?
124    } else {
125        // Fallback to master if main doesn't exist
126        let master_resp = client
127            .get(&format!("{}/git/ref/heads/master", base_url))
128            .send()
129            .map_err(|e| format!("Req failed: {}", e))?;
130        handle_response(master_resp)?
131    };
132
133    let latest_commit_sha = ref_json["object"]["sha"].as_str().unwrap_or_default();
134    let ref_name = ref_json["ref"].as_str().unwrap_or_default();
135
136    // 2. Get the base tree SHA
137    let commit_resp = client
138        .get(&format!("{}/git/commits/{}", base_url, latest_commit_sha))
139        .send()
140        .map_err(|e| format!("Req failed: {}", e))?;
141    let commit_json = handle_response(commit_resp)?;
142    let base_tree_sha = commit_json["tree"]["sha"].as_str().unwrap_or_default();
143
144    // 3. Create a new Tree
145    let mut tree_nodes = Vec::new();
146    for (path, content) in files {
147        tree_nodes.push(json!({
148            "path": path,
149            "mode": "100644",
150            "type": "blob",
151            "content": content
152        }));
153    }
154
155    let tree_resp = client
156        .post(&format!("{}/git/trees", base_url))
157        .json(&json!({
158            "base_tree": base_tree_sha,
159            "tree": tree_nodes
160        }))
161        .send()
162        .map_err(|e| format!("Req failed: {}", e))?;
163    let new_tree_json = handle_response(tree_resp)?;
164    let new_tree_sha = new_tree_json["sha"].as_str().unwrap_or_default();
165
166    // 4. Create a new Commit
167    let commit_resp = client
168        .post(&format!("{}/git/commits", base_url))
169        .json(&json!({
170            "message": "Deploy static site from Webizen",
171            "tree": new_tree_sha,
172            "parents": [latest_commit_sha]
173        }))
174        .send()
175        .map_err(|e| format!("Req failed: {}", e))?;
176    let new_commit_json = handle_response(commit_resp)?;
177    let new_commit_sha = new_commit_json["sha"].as_str().unwrap_or_default();
178
179    // 5. Update the Reference
180    let _update_ref_resp = client
181        .patch(&format!("{}/git/{}", base_url, ref_name))
182        .json(&json!({
183            "sha": new_commit_sha,
184            "force": false
185        }))
186        .send()
187        .map_err(|e| format!("Req failed: {}", e))?;
188
189    Ok(())
190}