Skip to main content

routa_server/api/
github.rs

1//! GitHub Virtual Workspace API - /api/github
2//!
3//! Provides in-memory virtual file system for GitHub repos.
4//! In the web (Next.js) backend these are backed by a full download+cache layer.
5//! In the Rust desktop backend we provide the same REST surface but route calls
6//! through git clone/local checkout (if available) or return helpful stubs.
7//!
8//! GET    /api/github              - List active imported GitHub workspaces
9//! POST   /api/github/import       - Import a GitHub repo as a virtual workspace
10//! GET    /api/github/tree         - Get file tree for an imported repo
11//! GET    /api/github/file         - Read a file from an imported repo
12//! GET    /api/github/search       - Search files in an imported repo
13
14use axum::{
15    extract::{Query, State},
16    routing::{get, post},
17    Json, Router,
18};
19use serde::Deserialize;
20
21use crate::api::tasks_github::{
22    github_access_status, list_github_issues, list_github_pulls, resolve_github_repo_for_codebase,
23};
24use crate::error::ServerError;
25use crate::state::AppState;
26
27pub fn router() -> Router<AppState> {
28    Router::new()
29        .route("/", get(list_workspaces))
30        .route("/access", get(github_access))
31        .route("/import", post(import_repo))
32        .route("/issues", get(list_issues))
33        .route("/pulls", get(list_pulls))
34        .route("/tree", get(get_tree))
35        .route("/file", get(get_file))
36        .route("/search", get(search_files))
37        .route("/pr-comment", post(post_pr_comment))
38}
39
40// ─── List workspaces ─────────────────────────────────────────────────────────
41
42async fn list_workspaces() -> Json<serde_json::Value> {
43    // Desktop mode: no in-memory cache yet — return empty list.
44    Json(serde_json::json!({ "workspaces": [] }))
45}
46
47async fn github_access() -> Json<serde_json::Value> {
48    let (source, available) = github_access_status();
49    Json(serde_json::json!({
50        "available": available,
51        "source": source,
52    }))
53}
54
55// ─── Import ──────────────────────────────────────────────────────────────────
56
57#[derive(Debug, Deserialize)]
58struct ImportRequest {
59    owner: Option<String>,
60    repo: Option<String>,
61    #[serde(rename = "ref")]
62    git_ref: Option<String>,
63    url: Option<String>,
64}
65
66async fn import_repo(Json(body): Json<ImportRequest>) -> Json<serde_json::Value> {
67    // Resolve owner/repo from either explicit fields or the `url` shorthand.
68    let (owner, repo) = if let (Some(owner), Some(repo)) = (&body.owner, &body.repo) {
69        (owner.clone(), repo.clone())
70    } else if let Some(url) = &body.url {
71        // Parse "https://github.com/owner/repo" or "owner/repo"
72        let stripped = url
73            .trim_start_matches("https://github.com/")
74            .trim_start_matches("http://github.com/");
75        let parts: Vec<&str> = stripped.splitn(2, '/').collect();
76        if parts.len() == 2 {
77            (parts[0].to_string(), parts[1].to_string())
78        } else {
79            return Json(serde_json::json!({
80                "error": "Invalid GitHub URL. Expected: https://github.com/owner/repo or owner/repo",
81                "code": "BAD_REQUEST"
82            }));
83        }
84    } else {
85        return Json(serde_json::json!({
86            "error": "Missing 'owner' and 'repo' fields (or provide 'url')",
87            "code": "BAD_REQUEST"
88        }));
89    };
90
91    let git_ref = body.git_ref.as_deref().unwrap_or("HEAD");
92
93    // In the desktop backend, GitHub import is not yet implemented.
94    // Clients should use the local git clone API instead.
95    Json(serde_json::json!({
96        "error": "GitHub virtual workspace import is not available in desktop mode. Use POST /api/clone to work with local repositories.",
97        "code": "NOT_IMPLEMENTED",
98        "hint": {
99            "owner": owner,
100            "repo": repo,
101            "ref": git_ref,
102            "alternative": "/api/clone"
103        }
104    }))
105}
106
107// ─── Shared query params ──────────────────────────────────────────────────────
108
109#[derive(Debug, Deserialize)]
110#[allow(dead_code)]
111struct RepoQuery {
112    owner: Option<String>,
113    repo: Option<String>,
114    #[serde(rename = "ref")]
115    git_ref: Option<String>,
116}
117
118fn not_imported(owner: &str, repo: &str) -> Json<serde_json::Value> {
119    Json(serde_json::json!({
120        "error": format!(
121            "Workspace not imported. POST /api/github/import first for {}/{}",
122            owner, repo
123        ),
124        "code": "NOT_FOUND"
125    }))
126}
127
128// ─── Tree ────────────────────────────────────────────────────────────────────
129
130async fn get_tree(Query(q): Query<RepoQuery>) -> Json<serde_json::Value> {
131    let owner = q.owner.as_deref().unwrap_or("");
132    let repo = q.repo.as_deref().unwrap_or("");
133    if owner.is_empty() || repo.is_empty() {
134        return Json(serde_json::json!({
135            "error": "Missing 'owner' and 'repo' query parameters",
136            "code": "BAD_REQUEST"
137        }));
138    }
139    not_imported(owner, repo)
140}
141
142// ─── File ────────────────────────────────────────────────────────────────────
143
144#[derive(Debug, Deserialize)]
145struct FileQuery {
146    owner: Option<String>,
147    repo: Option<String>,
148    path: Option<String>,
149    #[serde(rename = "ref")]
150    _git_ref: Option<String>,
151}
152
153async fn get_file(Query(q): Query<FileQuery>) -> Json<serde_json::Value> {
154    let owner = q.owner.as_deref().unwrap_or("");
155    let repo = q.repo.as_deref().unwrap_or("");
156    if owner.is_empty() || repo.is_empty() || q.path.is_none() {
157        return Json(serde_json::json!({
158            "error": "Missing 'owner', 'repo', or 'path' query parameters",
159            "code": "BAD_REQUEST"
160        }));
161    }
162    not_imported(owner, repo)
163}
164
165// ─── Search ──────────────────────────────────────────────────────────────────
166
167#[derive(Debug, Deserialize)]
168#[allow(dead_code)]
169struct SearchQuery {
170    owner: Option<String>,
171    repo: Option<String>,
172    q: Option<String>,
173    #[serde(rename = "ref")]
174    _git_ref: Option<String>,
175    limit: Option<usize>,
176}
177
178async fn search_files(Query(q): Query<SearchQuery>) -> Json<serde_json::Value> {
179    let owner = q.owner.as_deref().unwrap_or("");
180    let repo = q.repo.as_deref().unwrap_or("");
181    if owner.is_empty() || repo.is_empty() {
182        return Json(serde_json::json!({
183            "error": "Missing 'owner' and 'repo' query parameters",
184            "code": "BAD_REQUEST"
185        }));
186    }
187    // Return empty results rather than a hard error so callers can degrade gracefully.
188    Json(serde_json::json!({
189        "files": [],
190        "total": 0,
191        "query": q.q.as_deref().unwrap_or(""),
192        "note": "GitHub virtual workspaces are not available in desktop mode."
193    }))
194}
195
196// ─── Issues ──────────────────────────────────────────────────────────────────
197
198#[derive(Debug, Deserialize)]
199#[serde(rename_all = "camelCase")]
200struct IssueQuery {
201    workspace_id: Option<String>,
202    codebase_id: Option<String>,
203    state: Option<String>,
204}
205
206async fn list_issues(
207    State(state): State<AppState>,
208    Query(q): Query<IssueQuery>,
209) -> Result<Json<serde_json::Value>, ServerError> {
210    let workspace_id = q
211        .workspace_id
212        .as_deref()
213        .map(str::trim)
214        .filter(|value| !value.is_empty())
215        .ok_or_else(|| ServerError::BadRequest("workspaceId is required".to_string()))?;
216    let state_filter = match q.state.as_deref().unwrap_or("open") {
217        "open" | "closed" | "all" => q.state.as_deref().unwrap_or("open"),
218        _ => {
219            return Err(ServerError::BadRequest(
220                "state must be one of: open, closed, all".to_string(),
221            ))
222        }
223    };
224
225    let workspace_codebases = state.codebase_store.list_by_workspace(workspace_id).await?;
226    if workspace_codebases.is_empty() {
227        return Err(ServerError::NotFound(
228            "No codebases linked to this workspace".to_string(),
229        ));
230    }
231
232    let codebase = match q.codebase_id.as_deref() {
233        Some(codebase_id) => workspace_codebases
234            .iter()
235            .find(|item| item.id == codebase_id)
236            .cloned(),
237        None => workspace_codebases
238            .iter()
239            .find(|item| item.is_default)
240            .cloned()
241            .or_else(|| workspace_codebases.first().cloned()),
242    }
243    .ok_or_else(|| ServerError::NotFound("Codebase not found in this workspace".to_string()))?;
244
245    let repo = resolve_github_repo_for_codebase(
246        codebase.source_url.as_deref(),
247        Some(codebase.repo_path.as_str()),
248    )
249    .ok_or_else(|| {
250        ServerError::BadRequest(
251            "Selected codebase is not linked to a GitHub repository.".to_string(),
252        )
253    })?;
254
255    let issues = list_github_issues(&repo, Some(state_filter), Some(50))
256        .await
257        .map_err(ServerError::Internal)?;
258
259    Ok(Json(serde_json::json!({
260        "repo": repo,
261        "codebase": {
262            "id": codebase.id,
263            "label": codebase.label.clone().unwrap_or_else(|| {
264                std::path::Path::new(&codebase.repo_path)
265                    .file_name()
266                    .and_then(|value| value.to_str())
267                    .unwrap_or(&codebase.repo_path)
268                    .to_string()
269            }),
270        },
271        "issues": issues,
272    })))
273}
274
275#[derive(Debug, Deserialize)]
276#[serde(rename_all = "camelCase")]
277struct PullQuery {
278    workspace_id: Option<String>,
279    codebase_id: Option<String>,
280    state: Option<String>,
281}
282
283async fn list_pulls(
284    State(state): State<AppState>,
285    Query(q): Query<PullQuery>,
286) -> Result<Json<serde_json::Value>, ServerError> {
287    let workspace_id = q
288        .workspace_id
289        .as_deref()
290        .map(str::trim)
291        .filter(|value| !value.is_empty())
292        .ok_or_else(|| ServerError::BadRequest("workspaceId is required".to_string()))?;
293    let state_filter = match q.state.as_deref().unwrap_or("open") {
294        "open" | "closed" | "all" => q.state.as_deref().unwrap_or("open"),
295        _ => {
296            return Err(ServerError::BadRequest(
297                "state must be one of: open, closed, all".to_string(),
298            ))
299        }
300    };
301
302    let workspace_codebases = state.codebase_store.list_by_workspace(workspace_id).await?;
303    if workspace_codebases.is_empty() {
304        return Err(ServerError::NotFound(
305            "No codebases linked to this workspace".to_string(),
306        ));
307    }
308
309    let codebase = match q.codebase_id.as_deref() {
310        Some(codebase_id) => workspace_codebases
311            .iter()
312            .find(|item| item.id == codebase_id)
313            .cloned(),
314        None => workspace_codebases
315            .iter()
316            .find(|item| item.is_default)
317            .cloned()
318            .or_else(|| workspace_codebases.first().cloned()),
319    }
320    .ok_or_else(|| ServerError::NotFound("Codebase not found in this workspace".to_string()))?;
321
322    let repo = resolve_github_repo_for_codebase(
323        codebase.source_url.as_deref(),
324        Some(codebase.repo_path.as_str()),
325    )
326    .ok_or_else(|| {
327        ServerError::BadRequest(
328            "Selected codebase is not linked to a GitHub repository.".to_string(),
329        )
330    })?;
331
332    let pulls = list_github_pulls(&repo, Some(state_filter), Some(50))
333        .await
334        .map_err(ServerError::Internal)?;
335
336    Ok(Json(serde_json::json!({
337        "repo": repo,
338        "codebase": {
339            "id": codebase.id,
340            "label": codebase.label.clone().unwrap_or_else(|| {
341                std::path::Path::new(&codebase.repo_path)
342                    .file_name()
343                    .and_then(|value| value.to_str())
344                    .unwrap_or(&codebase.repo_path)
345                    .to_string()
346            }),
347        },
348        "pulls": pulls,
349    })))
350}
351
352// ─── PR Comment ───────────────────────────────────────────────────────────────
353
354#[derive(Debug, serde::Deserialize)]
355struct PrCommentRequest {
356    owner: Option<String>,
357    repo: Option<String>,
358    #[serde(rename = "prNumber")]
359    pr_number: Option<u64>,
360    body: Option<String>,
361}
362
363/// POST /api/github/pr-comment — Post a comment on a GitHub pull request
364async fn post_pr_comment(Json(body): Json<PrCommentRequest>) -> Json<serde_json::Value> {
365    let owner = body.owner.as_deref().unwrap_or("");
366    let repo = body.repo.as_deref().unwrap_or("");
367    let pr_number = body.pr_number.unwrap_or(0);
368
369    if owner.is_empty() || repo.is_empty() || pr_number == 0 || body.body.is_none() {
370        return Json(serde_json::json!({
371            "error": "Missing required fields: owner, repo, prNumber, body",
372            "code": "BAD_REQUEST"
373        }));
374    }
375
376    // Desktop mode: GitHub API calls require a token and HTTP access.
377    Json(serde_json::json!({
378        "error": "GitHub PR comments are not available in desktop mode. Configure GITHUB_TOKEN and use the web backend.",
379        "code": "NOT_IMPLEMENTED",
380        "hint": {
381            "owner": owner,
382            "repo": repo,
383            "prNumber": pr_number,
384        }
385    }))
386}