1use 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
39async fn list_workspaces() -> Json<serde_json::Value> {
42 Json(serde_json::json!({ "workspaces": [] }))
44}
45
46#[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 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 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 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#[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
119async 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#[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#[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 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#[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#[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
354async 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 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}