use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use super::sessions::ErrorResponse;
use crate::web::github::{self, CreateIssueRequest, UpdateIssueRequest};
use crate::web::state::WebState;
use super::fleet::{collect_fleet_repos, workspace_root};
fn no_github() -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "GitHub token not configured".to_string(),
}),
)
}
fn github_error(e: anyhow::Error) -> (StatusCode, Json<ErrorResponse>) {
let message = e.to_string();
if message.contains("status 401") || message.contains("status 403") {
return (
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "GitHub token is invalid or lacks required access".to_string(),
}),
);
}
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: message,
}),
)
}
#[derive(Deserialize)]
pub struct ListIssuesQuery {
pub state: Option<String>,
pub repo: Option<String>,
}
pub async fn list_issues(
State(state): State<WebState>,
Query(params): Query<ListIssuesQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let github = state.github.lock().await;
let client = github.as_ref().ok_or_else(no_github)?;
let issue_state = params.state.as_deref().unwrap_or("open");
let repo_root = workspace_root()?;
let mut all_repos = collect_fleet_repos(&repo_root)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: e.to_string(),
}),
)
})?
.into_iter()
.map(|repo| (repo.github.owner, repo.github.repo))
.collect::<Vec<_>>();
if let Some(ref filter_repo) = params.repo {
all_repos.retain(|(o, r)| format!("{}/{}", o, r) == *filter_repo);
}
let mut all_issues: Vec<serde_json::Value> = Vec::new();
let mut failures: Vec<serde_json::Value> = Vec::new();
for (owner, repo) in &all_repos {
match client.list_issues(owner, repo, issue_state).await {
Ok(issues) => {
for issue in issues {
let mut val = serde_json::to_value(&issue).unwrap();
val.as_object_mut()
.unwrap()
.insert("repo".to_string(), serde_json::json!(format!("{}/{}", owner, repo)));
all_issues.push(val);
}
}
Err(error) => {
let (status, body) = github_error(error);
if status == StatusCode::UNAUTHORIZED {
return Err((status, body));
}
failures.push(serde_json::json!({
"repo": format!("{}/{}", owner, repo),
"error": body.0.error,
}));
}
}
}
all_issues.sort_by(|a, b| {
let a_date = a.get("updated_at").and_then(|d| d.as_str()).unwrap_or("");
let b_date = b.get("updated_at").and_then(|d| d.as_str()).unwrap_or("");
b_date.cmp(a_date)
});
Ok(Json(serde_json::json!({
"issues": all_issues,
"failures": failures,
})))
}
pub async fn list_repo_issues(
State(state): State<WebState>,
Path((owner, repo)): Path<(String, String)>,
Query(params): Query<ListIssuesQuery>,
) -> Result<Json<Vec<github::GitHubIssue>>, (StatusCode, Json<ErrorResponse>)> {
let github = state.github.lock().await;
let client = github.as_ref().ok_or_else(no_github)?;
let issue_state = params.state.as_deref().unwrap_or("open");
let issues = client
.list_issues(&owner, &repo, issue_state)
.await
.map_err(github_error)?;
Ok(Json(issues))
}
pub async fn create_issue(
State(state): State<WebState>,
Path((owner, repo)): Path<(String, String)>,
Json(body): Json<CreateIssueRequest>,
) -> Result<Json<github::GitHubIssue>, (StatusCode, Json<ErrorResponse>)> {
let github = state.github.lock().await;
let client = github.as_ref().ok_or_else(no_github)?;
let issue = client
.create_issue(&owner, &repo, &body)
.await
.map_err(github_error)?;
Ok(Json(issue))
}
pub async fn update_issue(
State(state): State<WebState>,
Path((owner, repo, number)): Path<(String, String, i64)>,
Json(body): Json<UpdateIssueRequest>,
) -> Result<Json<github::GitHubIssue>, (StatusCode, Json<ErrorResponse>)> {
let github = state.github.lock().await;
let client = github.as_ref().ok_or_else(no_github)?;
let issue = client
.update_issue(&owner, &repo, number, &body)
.await
.map_err(github_error)?;
Ok(Json(issue))
}
#[derive(Deserialize)]
pub struct CommentRequest {
pub body: String,
}
pub async fn list_comments(
State(state): State<WebState>,
Path((owner, repo, number)): Path<(String, String, i64)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let github = state.github.lock().await;
let client = github.as_ref().ok_or_else(no_github)?;
let comments = client
.list_comments(&owner, &repo, number)
.await
.map_err(github_error)?;
Ok(Json(serde_json::json!({ "comments": comments })))
}
pub async fn add_comment(
State(state): State<WebState>,
Path((owner, repo, number)): Path<(String, String, i64)>,
Json(body): Json<CommentRequest>,
) -> Result<Json<github::GitHubComment>, (StatusCode, Json<ErrorResponse>)> {
let github = state.github.lock().await;
let client = github.as_ref().ok_or_else(no_github)?;
let comment = client
.add_comment(&owner, &repo, number, &body.body)
.await
.map_err(github_error)?;
Ok(Json(comment))
}
#[cfg(test)]
mod tests {
use super::github_error;
use axum::http::StatusCode;
#[test]
fn list_issues_response_shape_wraps_issues() {
let payload = serde_json::json!({
"issues": [{
"number": 1,
"title": "Broken contract",
"repo": "example/repo"
}],
"failures": [{
"repo": "example/bad-repo",
"error": "GitHub API error listing issues"
}]
});
assert!(payload.get("issues").unwrap().is_array());
assert!(payload.get("failures").unwrap().is_array());
}
#[test]
fn list_comments_response_shape_wraps_comments() {
let payload = serde_json::json!({
"comments": [{
"id": 1,
"body": "Looks good"
}]
});
assert!(payload.get("comments").unwrap().is_array());
}
#[test]
fn github_auth_errors_map_to_unauthorized() {
let (status, body) = github_error(anyhow::anyhow!("failed with GitHub API status 401"));
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body.0.error, "GitHub token is invalid or lacks required access");
}
}