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