1use 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
40async fn list_workspaces() -> Json<serde_json::Value> {
43 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#[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 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 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 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#[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
128async 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#[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#[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 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#[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#[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
363async 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 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}