use crate::server::app::AppState;
use crate::server::body::{read_json_object, string_field};
use crate::server::errors::error;
use crate::server::errors::method_not_allowed;
use crate::server::query::{js_number_or, js_number_string};
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use nomoreide_core::git_manager::GitManager;
use nomoreide_core::github_context::{optional_github_context, require_github_context};
use nomoreide_core::github_manager::GithubManager;
use serde::Deserialize;
use serde_json::{json, Map, Value};
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/api/github/branches", get(branches))
.route("/api/github/prs", get(list_prs).post(create_pr))
.route("/api/github/prs/:number", get(get_pr))
.route("/api/github/prs/:number/merge", post(merge_pr))
.route("/api/github/prs/:number/review", get(review))
.route("/api/github/prs/:number/diff", get(pr_diff))
.route("/api/github/issues", get(list_issues).post(create_issue))
.route("/api/github/issues/:number", get(get_issue))
.route(
"/api/github/issues/:number/comments",
get(list_comments)
.post(add_comment)
.fallback(method_not_allowed),
)
.route("/api/github/ci/:sha", get(commit_ci))
.route("/api/github/runs", get(list_runs))
.route("/api/github/runs/:run_id/jobs", get(run_jobs))
}
async fn branches(State(state): State<AppState>) -> Response {
let cwd = state.workspace_cwd().await;
let manager = match manager_for(&state, &cwd).await {
Ok(manager) => manager,
Err(response) => return response,
};
let (repository, branches, status) = tokio::join!(
manager.repo_info(),
manager.list_branches(),
GitManager::status(&cwd),
);
let repository = match repository {
Ok(repository) => repository,
Err(reason) => return refused(&reason.message),
};
let branches = match branches {
Ok(branches) => branches,
Err(reason) => return refused(&reason.message),
};
let current = status
.ok()
.map(|status| status.branch)
.filter(|branch| !branch.is_empty());
Json(json!({
"ok": true,
"repository": repository,
"defaultBranch": nullish(repository.get("default_branch")),
"currentBranch": current,
"branches": branches,
}))
.into_response()
}
#[derive(Deserialize)]
struct ListQuery {
#[serde(default)]
repository: Option<String>,
#[serde(default)]
state: Option<String>,
#[serde(default)]
page: Option<String>,
#[serde(default)]
branch: Option<String>,
}
impl ListQuery {
fn state(&self) -> &str {
match self.state.as_deref() {
Some(state) if !state.is_empty() => state,
_ => "open",
}
}
fn page(&self) -> String {
js_number_string(js_number_or(self.page.as_deref(), 1.0))
}
}
async fn list_prs(State(state): State<AppState>, Query(query): Query<ListQuery>) -> Response {
let manager = match manager_for_repository(&state, query.repository.as_deref()).await {
Ok(manager) => manager,
Err(response) => return response,
};
match manager.list_prs(query.state(), &query.page()).await {
Ok(prs) => Json(json!({ "ok": true, "prs": prs })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn create_pr(State(state): State<AppState>, body: Bytes) -> Response {
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
let body = read_json_object(&body);
let title = trimmed(&body, "title");
let head = trimmed(&body, "head");
let base = trimmed(&body, "base");
if title.is_empty() || head.is_empty() || base.is_empty() {
return refused("title, head, and base are required");
}
match manager
.create_pr(
&title,
string_field(&body, "body"),
&head,
&base,
body.get("draft") == Some(&Value::Bool(true)),
)
.await
{
Ok(pr) => Json(json!({ "ok": true, "pr": pr })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn get_pr(
State(state): State<AppState>,
Path(number): Path<String>,
Query(query): Query<ListQuery>,
) -> Response {
let Some(number) = numeric_path(&number) else {
return not_found();
};
let manager = match manager_for_repository(&state, query.repository.as_deref()).await {
Ok(manager) => manager,
Err(response) => return response,
};
match manager.get_pr(number).await {
Ok(pr) => Json(json!({ "ok": true, "pr": pr })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn merge_pr(
State(state): State<AppState>,
Path(number): Path<String>,
body: Bytes,
) -> Response {
let Some(number) = numeric_path(&number) else {
return not_found();
};
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
let body = read_json_object(&body);
let method = string_field(&body, "method")
.filter(|method| matches!(*method, "merge" | "squash" | "rebase"))
.unwrap_or("squash");
match manager
.merge_pr(
number,
method,
string_field(&body, "commitTitle"),
string_field(&body, "commitMessage"),
)
.await
{
Ok(result) => {
let mut answer = Map::new();
answer.insert("ok".into(), Value::Bool(true));
if let Value::Object(fields) = result {
answer.extend(fields);
}
Json(Value::Object(answer)).into_response()
}
Err(reason) => refused(&reason.message),
}
}
async fn review(State(state): State<AppState>, Path(number): Path<String>) -> Response {
let Some(number) = numeric_path(&number) else {
return not_found();
};
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
let pr = match manager.get_pr(number).await {
Ok(pr) => pr,
Err(reason) => return refused(&reason.message),
};
let sha = pr
.head
.get("sha")
.and_then(Value::as_str)
.map(str::to_string);
let (files, reviews, comments, checks) = tokio::join!(
manager.list_pr_files(number),
manager.list_pr_reviews(number),
manager.list_issue_comments(number),
manager.commit_checks(sha.as_deref()),
);
let files = match files {
Ok(files) => files,
Err(reason) => return refused(&reason.message),
};
let reviews = match reviews {
Ok(reviews) => reviews,
Err(reason) => return refused(&reason.message),
};
let comments = match comments {
Ok(comments) => comments,
Err(reason) => return refused(&reason.message),
};
let checks = match checks {
Ok(checks) => checks,
Err(reason) => return refused(&reason.message),
};
Json(json!({
"ok": true,
"cockpit": {
"pr": pr,
"files": files,
"reviews": reviews,
"comments": comments,
"checks": checks,
},
}))
.into_response()
}
async fn pr_diff(State(state): State<AppState>, Path(number): Path<String>) -> Response {
let Some(number) = numeric_path(&number) else {
return not_found();
};
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
match manager.pr_diff(number).await {
Ok(diff) => (
[(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
)],
diff,
)
.into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn list_issues(State(state): State<AppState>, Query(query): Query<ListQuery>) -> Response {
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
match manager.list_issues(query.state(), &query.page()).await {
Ok(issues) => Json(json!({ "ok": true, "issues": issues })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn create_issue(State(state): State<AppState>, body: Bytes) -> Response {
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
let body = read_json_object(&body);
let title = trimmed(&body, "title");
if title.is_empty() {
return refused("title is required");
}
match manager
.create_issue(&title, string_field(&body, "body"))
.await
{
Ok(issue) => Json(json!({ "ok": true, "issue": issue })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn get_issue(State(state): State<AppState>, Path(number): Path<String>) -> Response {
let Some(number) = numeric_path(&number) else {
return not_found();
};
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
match manager.get_issue(number).await {
Ok(issue) => Json(json!({ "ok": true, "issue": issue })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn list_comments(State(state): State<AppState>, Path(number): Path<String>) -> Response {
let Some(number) = numeric_path(&number) else {
return not_found();
};
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
match manager.list_issue_comments(number).await {
Ok(comments) => Json(json!({ "ok": true, "comments": comments })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn add_comment(
State(state): State<AppState>,
Path(number): Path<String>,
body: Bytes,
) -> Response {
let Some(number) = numeric_path(&number) else {
return not_found();
};
let manager = match selected_manager(&state).await {
Ok(manager) => manager,
Err(response) => return response,
};
let body = read_json_object(&body);
let text = trimmed(&body, "body");
if text.is_empty() {
return refused("body is required");
}
match manager.add_issue_comment(number, &text).await {
Ok(comment) => Json(json!({ "ok": true, "comment": comment })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn commit_ci(State(state): State<AppState>, Path(sha): Path<String>) -> Response {
if !is_sha(&sha) {
return not_found();
}
let cwd = state.workspace_cwd().await;
let Some(context) = optional_github_context(&state.config_store, &cwd).await else {
return Json(json!({
"ok": true,
"status": { "sha": sha, "state": "unknown", "totalCount": 0, "runs": [] },
}))
.into_response();
};
match context.manager.commit_checks(Some(&sha)).await {
Ok(status) => Json(json!({ "ok": true, "status": status })).into_response(),
Err(reason) => refused(&reason.message),
}
}
async fn list_runs(State(state): State<AppState>, Query(query): Query<ListQuery>) -> Response {
let manager = match manager_for_repository(&state, query.repository.as_deref()).await {
Ok(manager) => manager,
Err(response) => return response,
};
let branch = query.branch.as_deref().filter(|branch| !branch.is_empty());
match manager.list_workflow_runs(branch, &query.page()).await {
Ok(runs) => optional_field("runs", runs),
Err(reason) => refused(&reason.message),
}
}
async fn run_jobs(
State(state): State<AppState>,
Path(run_id): Path<String>,
Query(query): Query<ListQuery>,
) -> Response {
let Some(run_id) = numeric_path(&run_id) else {
return not_found();
};
let manager = match manager_for_repository(&state, query.repository.as_deref()).await {
Ok(manager) => manager,
Err(response) => return response,
};
match manager.list_workflow_run_jobs(run_id).await {
Ok(jobs) => optional_field("jobs", jobs),
Err(reason) => refused(&reason.message),
}
}
async fn selected_manager(state: &AppState) -> Result<GithubManager, Response> {
let cwd = state.workspace_cwd().await;
manager_for(state, &cwd).await
}
async fn manager_for_repository(
state: &AppState,
repository: Option<&str>,
) -> Result<GithubManager, Response> {
let Some(name) = repository.map(str::trim).filter(|name| !name.is_empty()) else {
return selected_manager(state).await;
};
let config = match state.config_store.load().await {
Ok(config) => config,
Err(_) => return Err(refused("Could not read the configuration")),
};
let Some(found) = config
.git_repositories
.iter()
.find(|repository| repository.name == name)
else {
return Err(not_found());
};
let cwd = found
.active_worktree_path
.clone()
.unwrap_or_else(|| found.path.clone());
manager_for(state, &cwd).await
}
async fn manager_for(state: &AppState, cwd: &str) -> Result<GithubManager, Response> {
require_github_context(&state.config_store, cwd)
.await
.map(|context| context.manager)
.map_err(|reason| refused(&reason.to_string()))
}
fn optional_field(name: &str, value: Option<Value>) -> Response {
let mut answer = Map::new();
answer.insert("ok".into(), Value::Bool(true));
if let Some(value) = value {
answer.insert(name.to_string(), value);
}
Json(Value::Object(answer)).into_response()
}
fn refused(message: &str) -> Response {
error(StatusCode::BAD_REQUEST, message)
}
fn not_found() -> Response {
(
StatusCode::NOT_FOUND,
[(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)],
"Not found",
)
.into_response()
}
fn numeric_path(value: &str) -> Option<i64> {
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
Some(value.parse::<i64>().unwrap_or(i64::MAX))
}
fn is_sha(value: &str) -> bool {
(4..=64).contains(&value.len())
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn trimmed(body: &Value, key: &str) -> String {
string_field(body, key)
.unwrap_or_default()
.trim()
.to_string()
}
fn nullish(value: Option<&Value>) -> Value {
match value {
Some(Value::Null) | None => Value::Null,
Some(value) => value.clone(),
}
}