Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src-tauri/src/commands/forge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,9 +618,37 @@ pub async fn forge_list_issues_core(
ForgeProvider::GitHub => forge::github::list_issues(&auth, &request).await?,
ForgeProvider::GitLab => forge::gitlab::list_issues(&auth, &request).await?,
ForgeProvider::Gitea => forge::gitea::list_issues(&auth, &request).await?,
ForgeProvider::Cnb => forge::cnb::list_issues(&auth, &request).await?,
})
}

/// One issue, read in full — for the description its list row does not carry.
///
/// Only CNB needs this: its issue list omits `body` (its pull list carries
/// one), so the panel asks for the item itself the moment it opens, the same
/// way it asks for the comment thread. Every other forge ships the body on the
/// row and never calls this, which is why no other arm is implemented — one
/// arriving is a wiring mistake and says so rather than returning a row that
/// looks like an issue with no description.
pub async fn forge_issue_detail_core(
db: &AppDatabase,
folder_id: i32,
number: i64,
account_id: Option<String>,
) -> Result<forge::ForgeIssueRow, AppCommandError> {
let (remote, auth) = resolve_folder_repo(db, folder_id, account_id.as_deref()).await?;
match remote.provider {
ForgeProvider::Cnb => {
Ok(forge::cnb::issue_detail(&auth, &remote.owner_repo, number).await?)
}
other => Err(ForgeError::Invalid(format!(
"{} serves the issue body with its list rows; only CNB needs a detail read",
other.display_name()
))
.into()),
}
}

/// One tab's item count under a set of filters — a badge on the workbench's
/// Issues/PR switcher.
///
Expand Down Expand Up @@ -651,6 +679,7 @@ pub async fn forge_tab_count_core(
ForgeProvider::GitHub => forge::github::list_issues(&auth, &request).await,
ForgeProvider::GitLab => forge::gitlab::list_issues(&auth, &request).await,
ForgeProvider::Gitea => forge::gitea::list_issues(&auth, &request).await,
ForgeProvider::Cnb => forge::cnb::list_issues(&auth, &request).await,
};
Ok(listed.ok().and_then(|page| page.trustworthy_count()))
}
Expand Down Expand Up @@ -692,6 +721,12 @@ pub async fn forge_list_comments_core(
ForgeProvider::Gitea => {
forge::gitea::list_comments(&auth, &remote.owner_repo, number, page, per_page).await?
}
// CNB keeps issues and merge requests in separate collections, so the
// kind picks the endpoint exactly as it does on GitLab.
ForgeProvider::Cnb => {
forge::cnb::list_comments(&auth, &remote.owner_repo, kind, number, page, per_page)
.await?
}
})
}

Expand Down Expand Up @@ -728,6 +763,9 @@ pub async fn forge_create_comment_core(
ForgeProvider::Gitea => {
forge::gitea::create_comment(&auth, &remote.owner_repo, number, &body).await?
}
ForgeProvider::Cnb => {
forge::cnb::create_comment(&auth, &remote.owner_repo, kind, number, &body).await?
}
})
}

Expand Down Expand Up @@ -755,6 +793,9 @@ pub async fn forge_set_item_state_core(
ForgeProvider::Gitea => {
forge::gitea::set_item_state(&auth, &remote.owner_repo, kind, number, action).await?
}
ForgeProvider::Cnb => {
forge::cnb::set_item_state(&auth, &remote.owner_repo, kind, number, action).await?
}
})
}

Expand All @@ -780,6 +821,9 @@ pub async fn forge_create_issue_core(
ForgeProvider::Gitea => {
forge::gitea::create_issue(&auth, &remote.owner_repo, &resolved).await?
}
ForgeProvider::Cnb => {
forge::cnb::create_issue(&auth, &remote.owner_repo, &resolved).await?
}
})
}

Expand Down Expand Up @@ -807,6 +851,9 @@ pub async fn forge_change_detail_core(
ForgeProvider::Gitea => {
forge::gitea::change_detail(&auth, &remote.owner_repo, number).await?
}
ForgeProvider::Cnb => {
forge::cnb::change_detail(&auth, &remote.owner_repo, number).await?
}
})
}

Expand Down Expand Up @@ -836,6 +883,10 @@ pub async fn forge_change_files_core(
forge::gitea::list_change_files(&auth, &remote.owner_repo, number, page, per_page)
.await?
}
ForgeProvider::Cnb => {
forge::cnb::list_change_files(&auth, &remote.owner_repo, number, page, per_page)
.await?
}
})
}

Expand Down Expand Up @@ -876,6 +927,7 @@ pub async fn forge_merge_options_core(
ForgeProvider::GitHub => forge::github::merge_options(&auth, &remote.owner_repo).await?,
ForgeProvider::GitLab => forge::gitlab::merge_options(&auth, &remote.owner_repo).await?,
ForgeProvider::Gitea => forge::gitea::merge_options(&auth, &remote.owner_repo).await?,
ForgeProvider::Cnb => forge::cnb::merge_options(&auth, &remote.owner_repo).await?,
})
}

Expand Down Expand Up @@ -909,6 +961,9 @@ pub async fn forge_merge_change_core(
ForgeProvider::Gitea => {
forge::gitea::merge_change(&auth, &remote.owner_repo, number, method, head_sha).await?
}
ForgeProvider::Cnb => {
forge::cnb::merge_change(&auth, &remote.owner_repo, number, method, head_sha).await?
}
})
}

Expand All @@ -926,6 +981,7 @@ pub async fn forge_list_labels_core(
ForgeProvider::GitHub => forge::github::list_labels(&auth, &remote.owner_repo).await?,
ForgeProvider::GitLab => forge::gitlab::list_labels(&auth, &remote.owner_repo).await?,
ForgeProvider::Gitea => forge::gitea::list_labels(&auth, &remote.owner_repo).await?,
ForgeProvider::Cnb => forge::cnb::list_labels(&auth, &remote.owner_repo).await?,
})
}

Expand Down Expand Up @@ -1028,6 +1084,9 @@ pub async fn create_forge_task(
ForgeProvider::Gitea => {
forge::gitea::get_pull(&auth, &owner_repo, source.number).await?
}
ForgeProvider::Cnb => {
forge::cnb::get_pull(&auth, &owner_repo, source.number).await?
}
};
// Refused here, at the only moment the user can still choose something
// else, rather than at the end of a task whose work has nowhere to go.
Expand Down Expand Up @@ -1297,6 +1356,17 @@ pub async fn forge_list_issues(
forge_list_issues_core(&db, folder_id, query).await
}

#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn forge_issue_detail(
db: tauri::State<'_, AppDatabase>,
folder_id: i32,
number: i64,
account_id: Option<String>,
) -> Result<forge::ForgeIssueRow, AppCommandError> {
forge_issue_detail_core(&db, folder_id, number, account_id).await
}

#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn forge_tab_count(
Expand Down
121 changes: 121 additions & 0 deletions src-tauri/src/commands/version_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,3 +503,124 @@ pub async fn validate_gitea_token(
message: None,
})
}

// ---------------------------------------------------------------------------
// CNB token validation
// ---------------------------------------------------------------------------

/// Validate a CNB access token against `GET /user`.
///
/// CNB is the one forge whose API is NOT under the instance origin: the web UI
/// is `https://cnb.cool`, the OpenAPI is `https://api.cnb.cool`, and the auth
/// header is `Bearer <token>`. The response shape is CNB's own —
/// `{username, nickname, avatar}`.
///
/// Scopes are not reported by this endpoint, so the list is empty. Nothing
/// gates on scopes (see `ResolvedAuth::scopes`); a token missing a permission
/// says so at the first API call, with CNB's own `errmsg`.
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn validate_cnb_token(
server_url: String,
token: String,
) -> Result<GitHubTokenValidation, AppCommandError> {
let trimmed_token = token.trim();
if trimmed_token.is_empty() {
return Err(AppCommandError::invalid_input("Token cannot be empty"));
}
let origin = {
let base = server_url.trim().trim_end_matches('/');
if base.is_empty() {
"https://cnb.cool".to_string()
} else if base.contains("://") {
base.to_string()
} else {
format!("https://{base}")
}
};
// The SaaS separates UI and API hosts; anything else is assumed to mount
// the API under `/api`, which is what `forge::auth::api_base_for` does.
let host = origin
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(&origin)
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.to_ascii_lowercase();
let api_base = if host == "cnb.cool" {
"https://api.cnb.cool".to_string()
} else {
format!("{origin}/api")
};

let response = reqwest::Client::new()
.get(format!("{api_base}/user"))
.header("Authorization", format!("Bearer {trimmed_token}"))
.header("User-Agent", "codeg")
.header("Accept", "application/json")
.send()
.await
.map_err(|e| {
AppCommandError::network("Failed to connect to CNB API").with_detail(e.to_string())
})?;

if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
let detail = serde_json::from_str::<CnbError>(&body)
.ok()
.and_then(|e| e.errmsg)
.filter(|m| !m.trim().is_empty())
.unwrap_or(body);
// 403 is NOT a bad token here: CNB answers it when the token is valid
// but was minted without the `account-profile:r` scope this check
// needs, and its `errmsg` names the missing scope. Reporting that as
// "invalid token" sends the user to re-mint the same token again.
let message = match status {
401 => "Invalid or expired token".to_string(),
403 => format!("The token is valid but lacks a required scope: {detail}"),
_ => format!("CNB API returned status {status}: {detail}"),
};
return Ok(GitHubTokenValidation {
success: false,
username: None,
scopes: vec![],
avatar_url: None,
message: Some(message),
});
}

let user = response.json::<CnbUserResponse>().await.map_err(|e| {
AppCommandError::network("Failed to parse CNB API response").with_detail(e.to_string())
})?;

Ok(GitHubTokenValidation {
success: true,
username: user
.username
.filter(|u| !u.trim().is_empty())
.or(user.nickname.filter(|n| !n.trim().is_empty())),
scopes: vec![],
avatar_url: user.avatar,
message: None,
})
}

#[derive(Debug, Deserialize)]
struct CnbUserResponse {
#[serde(default)]
username: Option<String>,
#[serde(default)]
nickname: Option<String>,
#[serde(default)]
avatar: Option<String>,
}

#[derive(Debug, Deserialize)]
struct CnbError {
#[serde(default)]
errmsg: Option<String>,
}
20 changes: 19 additions & 1 deletion src-tauri/src/forge/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ pub struct HostProfile {
/// `(github, host, owner/repo)` triple that no GitHub API can answer, and
/// the panel used to report that as an account or API failure. Whoever is
/// about to spend a request on a host checks this first and says "only
/// GitHub, GitLab and Gitea are supported" instead.
/// GitHub, GitLab, Gitea and CNB are supported" instead.
///
/// Deliberately OPTIMISTIC about a configured host, and the reason is that
/// a provider-less account is ambiguous by construction. Two flows write
Expand Down Expand Up @@ -359,6 +359,13 @@ pub fn provider_from_host_name(server_host: &str) -> Option<ForgeProvider> {
if names("gitea") || names("forgejo") {
return Some(ForgeProvider::Gitea);
}
// `cnb.cool` and its `api.cnb.cool` API host. The label is `cnb`, so a
// host like `cnb.example.com` would match too — there is no other
// platform that spells it that way, and the alternative is falling back
// to GitHub, which cannot answer a single CNB request.
if names("cnb") {
return Some(ForgeProvider::Cnb);
}
None
}

Expand Down Expand Up @@ -702,11 +709,22 @@ fn api_base_for(provider: ForgeProvider, host: &str, server_url: &str) -> String
if provider == ForgeProvider::GitHub && host == "github.com" {
return "https://api.github.com".to_string();
}
// CNB serves its OpenAPI from a dedicated host, not under the web origin:
// `cnb.cool` is the UI and `api.cnb.cool` is the API. Anything else is
// treated as an install that mounts the API under `/api`.
if provider == ForgeProvider::Cnb {
return if host == "cnb.cool" {
"https://api.cnb.cool".to_string()
} else {
format!("{}/api", server_origin(host, server_url))
};
}
let origin = server_origin(host, server_url);
match provider {
ForgeProvider::GitHub => format!("{origin}/api/v3"),
ForgeProvider::GitLab => format!("{origin}/api/v4"),
ForgeProvider::Gitea => format!("{origin}/api/v1"),
ForgeProvider::Cnb => unreachable!("handled above"),
}
}

Expand Down
5 changes: 4 additions & 1 deletion src-tauri/src/forge/auto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ impl AutoProcessor {
.ok_or("this folder has no recognizable forge remote (origin)")?;
if !remote.supported {
return Err(format!(
"{} is not a host codeg can read — only GitHub, GitLab and Gitea are supported",
"{} is not a host codeg can read — only GitHub, GitLab, Gitea and CNB are supported",
remote.server_host
));
}
Expand Down Expand Up @@ -843,6 +843,9 @@ async fn list_open_items(
ForgeProvider::Gitea => {
forge_core::gitea::list_open_items(auth, owner_repo, issues, pulls, since, from).await
}
ForgeProvider::Cnb => {
forge_core::cnb::list_open_items(auth, owner_repo, issues, pulls, since, from).await
}
}
}

Expand Down
Loading