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,
16    routing::{get, post},
17    Json, Router,
18};
19use serde::Deserialize;
20
21use crate::state::AppState;
22
23pub fn router() -> Router<AppState> {
24    Router::new()
25        .route("/", get(list_workspaces))
26        .route("/import", post(import_repo))
27        .route("/tree", get(get_tree))
28        .route("/file", get(get_file))
29        .route("/search", get(search_files))
30        .route("/pr-comment", post(post_pr_comment))
31}
32
33// ─── List workspaces ─────────────────────────────────────────────────────────
34
35async fn list_workspaces() -> Json<serde_json::Value> {
36    // Desktop mode: no in-memory cache yet — return empty list.
37    Json(serde_json::json!({ "workspaces": [] }))
38}
39
40// ─── Import ──────────────────────────────────────────────────────────────────
41
42#[derive(Debug, Deserialize)]
43struct ImportRequest {
44    owner: Option<String>,
45    repo: Option<String>,
46    #[serde(rename = "ref")]
47    git_ref: Option<String>,
48    url: Option<String>,
49}
50
51async fn import_repo(Json(body): Json<ImportRequest>) -> Json<serde_json::Value> {
52    // Resolve owner/repo from either explicit fields or the `url` shorthand.
53    let (owner, repo) = if let (Some(owner), Some(repo)) = (&body.owner, &body.repo) {
54        (owner.clone(), repo.clone())
55    } else if let Some(url) = &body.url {
56        // Parse "https://github.com/owner/repo" or "owner/repo"
57        let stripped = url
58            .trim_start_matches("https://github.com/")
59            .trim_start_matches("http://github.com/");
60        let parts: Vec<&str> = stripped.splitn(2, '/').collect();
61        if parts.len() == 2 {
62            (parts[0].to_string(), parts[1].to_string())
63        } else {
64            return Json(serde_json::json!({
65                "error": "Invalid GitHub URL. Expected: https://github.com/owner/repo or owner/repo",
66                "code": "BAD_REQUEST"
67            }));
68        }
69    } else {
70        return Json(serde_json::json!({
71            "error": "Missing 'owner' and 'repo' fields (or provide 'url')",
72            "code": "BAD_REQUEST"
73        }));
74    };
75
76    let git_ref = body.git_ref.as_deref().unwrap_or("HEAD");
77
78    // In the desktop backend, GitHub import is not yet implemented.
79    // Clients should use the local git clone API instead.
80    Json(serde_json::json!({
81        "error": "GitHub virtual workspace import is not available in desktop mode. Use POST /api/clone to work with local repositories.",
82        "code": "NOT_IMPLEMENTED",
83        "hint": {
84            "owner": owner,
85            "repo": repo,
86            "ref": git_ref,
87            "alternative": "/api/clone"
88        }
89    }))
90}
91
92// ─── Shared query params ──────────────────────────────────────────────────────
93
94#[derive(Debug, Deserialize)]
95#[allow(dead_code)]
96struct RepoQuery {
97    owner: Option<String>,
98    repo: Option<String>,
99    #[serde(rename = "ref")]
100    git_ref: Option<String>,
101}
102
103fn not_imported(owner: &str, repo: &str) -> Json<serde_json::Value> {
104    Json(serde_json::json!({
105        "error": format!(
106            "Workspace not imported. POST /api/github/import first for {}/{}",
107            owner, repo
108        ),
109        "code": "NOT_FOUND"
110    }))
111}
112
113// ─── Tree ────────────────────────────────────────────────────────────────────
114
115async fn get_tree(Query(q): Query<RepoQuery>) -> Json<serde_json::Value> {
116    let owner = q.owner.as_deref().unwrap_or("");
117    let repo = q.repo.as_deref().unwrap_or("");
118    if owner.is_empty() || repo.is_empty() {
119        return Json(serde_json::json!({
120            "error": "Missing 'owner' and 'repo' query parameters",
121            "code": "BAD_REQUEST"
122        }));
123    }
124    not_imported(owner, repo)
125}
126
127// ─── File ────────────────────────────────────────────────────────────────────
128
129#[derive(Debug, Deserialize)]
130struct FileQuery {
131    owner: Option<String>,
132    repo: Option<String>,
133    path: Option<String>,
134    #[serde(rename = "ref")]
135    _git_ref: Option<String>,
136}
137
138async fn get_file(Query(q): Query<FileQuery>) -> Json<serde_json::Value> {
139    let owner = q.owner.as_deref().unwrap_or("");
140    let repo = q.repo.as_deref().unwrap_or("");
141    if owner.is_empty() || repo.is_empty() || q.path.is_none() {
142        return Json(serde_json::json!({
143            "error": "Missing 'owner', 'repo', or 'path' query parameters",
144            "code": "BAD_REQUEST"
145        }));
146    }
147    not_imported(owner, repo)
148}
149
150// ─── Search ──────────────────────────────────────────────────────────────────
151
152#[derive(Debug, Deserialize)]
153#[allow(dead_code)]
154struct SearchQuery {
155    owner: Option<String>,
156    repo: Option<String>,
157    q: Option<String>,
158    #[serde(rename = "ref")]
159    _git_ref: Option<String>,
160    limit: Option<usize>,
161}
162
163async fn search_files(Query(q): Query<SearchQuery>) -> Json<serde_json::Value> {
164    let owner = q.owner.as_deref().unwrap_or("");
165    let repo = q.repo.as_deref().unwrap_or("");
166    if owner.is_empty() || repo.is_empty() {
167        return Json(serde_json::json!({
168            "error": "Missing 'owner' and 'repo' query parameters",
169            "code": "BAD_REQUEST"
170        }));
171    }
172    // Return empty results rather than a hard error so callers can degrade gracefully.
173    Json(serde_json::json!({
174        "files": [],
175        "total": 0,
176        "query": q.q.as_deref().unwrap_or(""),
177        "note": "GitHub virtual workspaces are not available in desktop mode."
178    }))
179}
180
181// ─── PR Comment ───────────────────────────────────────────────────────────────
182
183#[derive(Debug, serde::Deserialize)]
184struct PrCommentRequest {
185    owner: Option<String>,
186    repo: Option<String>,
187    #[serde(rename = "prNumber")]
188    pr_number: Option<u64>,
189    body: Option<String>,
190}
191
192/// POST /api/github/pr-comment — Post a comment on a GitHub pull request
193async fn post_pr_comment(Json(body): Json<PrCommentRequest>) -> Json<serde_json::Value> {
194    let owner = body.owner.as_deref().unwrap_or("");
195    let repo = body.repo.as_deref().unwrap_or("");
196    let pr_number = body.pr_number.unwrap_or(0);
197
198    if owner.is_empty() || repo.is_empty() || pr_number == 0 || body.body.is_none() {
199        return Json(serde_json::json!({
200            "error": "Missing required fields: owner, repo, prNumber, body",
201            "code": "BAD_REQUEST"
202        }));
203    }
204
205    // Desktop mode: GitHub API calls require a token and HTTP access.
206    Json(serde_json::json!({
207        "error": "GitHub PR comments are not available in desktop mode. Configure GITHUB_TOKEN and use the web backend.",
208        "code": "NOT_IMPLEMENTED",
209        "hint": {
210            "owner": owner,
211            "repo": repo,
212            "prNumber": pr_number,
213        }
214    }))
215}