diff --git a/src-tauri/src/commands/forge.rs b/src-tauri/src/commands/forge.rs index 05cceca6d12..70740e03bbb 100644 --- a/src-tauri/src/commands/forge.rs +++ b/src-tauri/src/commands/forge.rs @@ -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, +) -> Result { + 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. /// @@ -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())) } @@ -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? + } }) } @@ -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? + } }) } @@ -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? + } }) } @@ -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? + } }) } @@ -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? + } }) } @@ -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? + } }) } @@ -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?, }) } @@ -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? + } }) } @@ -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?, }) } @@ -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. @@ -1103,7 +1162,15 @@ pub async fn create_forge_task( standing.as_deref(), draft.instruction.as_deref(), ); - let envelope = forge_untrusted_envelope(provider.as_str(), &draft.snapshot); + // CNB's issue LIST carries no description, and both callers build their + // snapshot from a list row — so without this read the agent would get the + // title and an empty fence. Same reason the change above is hydrated: the + // card must not exist without what its prompt needs. + let mut snapshot = draft.snapshot.clone(); + if provider == ForgeProvider::Cnb && !is_pr { + forge::cnb::fill_issue_body(&auth, &owner_repo, source.number, &mut snapshot).await?; + } + let envelope = forge_untrusted_envelope(provider.as_str(), &snapshot); let blocks = vec![ serde_json::to_value(crate::acp::types::PromptInputBlock::Text { text: instruction.clone(), @@ -1297,6 +1364,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, +) -> Result { + 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( diff --git a/src-tauri/src/commands/version_control.rs b/src-tauri/src/commands/version_control.rs index 936247c51fd..adc3348a9bf 100644 --- a/src-tauri/src/commands/version_control.rs +++ b/src-tauri/src/commands/version_control.rs @@ -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 `. 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 { + 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::(&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::().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, + #[serde(default)] + nickname: Option, + #[serde(default)] + avatar: Option, +} + +#[derive(Debug, Deserialize)] +struct CnbError { + #[serde(default)] + errmsg: Option, +} diff --git a/src-tauri/src/forge/auth.rs b/src-tauri/src/forge/auth.rs index 1726c399fc2..58f765f7a1b 100644 --- a/src-tauri/src/forge/auth.rs +++ b/src-tauri/src/forge/auth.rs @@ -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 @@ -359,6 +359,13 @@ pub fn provider_from_host_name(server_host: &str) -> Option { 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 } @@ -562,7 +569,7 @@ async fn detect_forge(host: &str, origin: &str) -> Option { return verdict; } // The public hosts are not worth a round trip; their names ARE the answer. - if host == "github.com" || host == "gitlab.com" { + if host == "github.com" || host == "gitlab.com" || host == "cnb.cool" { return None; } let client = super::http_client().ok()?; @@ -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"), } } diff --git a/src-tauri/src/forge/auto/mod.rs b/src-tauri/src/forge/auto/mod.rs index b750f99cbdc..0446a37c6a1 100644 --- a/src-tauri/src/forge/auto/mod.rs +++ b/src-tauri/src/forge/auto/mod.rs @@ -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 )); } @@ -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 + } } } diff --git a/src-tauri/src/forge/cnb.rs b/src-tauri/src/forge/cnb.rs new file mode 100644 index 00000000000..706707a6645 --- /dev/null +++ b/src-tauri/src/forge/cnb.rs @@ -0,0 +1,1502 @@ +//! CNB (cnb.cool) OpenAPI client for the workbench and the delivery path. +//! +//! CNB is Tencent's code hosting platform. Its API is NOT a GitHub or Gitea +//! dialect, and the differences are the kind that produce a silently wrong +//! answer rather than a 404: +//! +//! 1. **The REST base is a different host.** `https://cnb.cool` is the web UI; +//! `https://api.cnb.cool` is the API. Deriving the API from the instance +//! origin the way GitHub Enterprise / GitLab / Gitea do sends every request +//! to an HTML page that answers `200`. +//! 2. **Authentication is `Authorization: Bearer `.** No `token` or +//! `PRIVATE-TOKEN` spelling works. +//! 3. **Every id is a STRING on the wire**, including an issue's `number`, and +//! every date is RFC 3339 with a `Z`. +//! 4. **Issues and merge requests are separate collections** (`/-/issues` and +//! `/-/pulls`), like GitLab, and the list payload for an ISSUE carries no +//! body — only the detail endpoint does. Rows therefore arrive with +//! `body: None`. +//! 5. **A state change needs two fields together.** `PATCH /issues/{n}` takes +//! `state` AND `state_reason`; sending only `state` is a parameter error. +//! 6. **Pagination is `page` + `page_size`** and neither collection reports a +//! total, so [`ForgeIssueList::total_count`] stays `None` and the UI falls +//! back to previous/next. +//! 7. **A merged merge request is `state: "closed"` with `is_merged: true`** — +//! the flag, not the state, is what tells "landed" from "abandoned". +//! 8. **`403` means a missing token SCOPE**, and `errmsg` names it. Each +//! endpoint documents its own (`repo-issue:r`, `repo-pr:rw`, +//! `repo-notes:r`, `account-profile:r` for `/user`). +//! +//! Errors use CNB's own envelope (`{"errcode":…,"errmsg":…}`), which is worth +//! surfacing: the message is the only thing that says which permission a +//! token is missing. + +use std::collections::HashMap; +use std::sync::{LazyLock, RwLock}; + +use serde::Deserialize; + +use super::auth::ResolvedAuth; +use super::deliver::{ForgePr, NewPullRequest}; +use super::{ + normalize_repo, sanitize_web_url, truncate_chars, urlencode_query, validate_state_filter, + web_origin, ForgeChangeDetail, ForgeChangedFile, ForgeChangedFileList, ForgeCheckList, + ForgeComment, ForgeCommentList, ForgeError, ForgeFileStatus, ForgeIssueList, ForgeIssueRow, + ForgeItemKind, ForgeLabel, ForgeLabelList, ForgeMergeMethod, ForgeMergeOptions, ForgeSort, + ForgeStateAction, ForgeTab, ListIssuesRequest, OpenItemsScan, ResolvedNewIssue, ScanCursor, + BODY_CAP, LABEL_PAGE_SIZE, SCAN_MAX_PAGES, +}; + +// ── reads ─────────────────────────────────────────────────────────────────── + +/// One page of the workbench list. +/// +/// Both tabs are served by their own collection, so real page numbers are +/// possible without a client-side split. Ordering is CNB's `order_by`, whose +/// vocabulary has no ASCENDING updated order — so the panel offers three of the +/// four orders for CNB (see `sortOptionsFor` in `forge-page.tsx`). +pub async fn list_issues( + auth: &ResolvedAuth, + req: &ListIssuesRequest, +) -> Result { + let repo = repo_ref(&req.owner_repo)?; + validate_state_filter(&req.state)?; + let (page, per_page) = req.clamped(); + + let collection = collection_of(req.tab); + let mut url = format!( + "{}/{repo}/-/{collection}?page={page}&page_size={per_page}&order_by={}", + auth.api_base, + order_by(req.sort) + ); + if let Some(state) = state_filter(req.tab, &req.state) { + url.push_str("&state="); + url.push_str(state); + } + if req.assigned_me { + let login = current_login(auth).await?; + url.push_str(&format!("&assignees={}", urlencode_query(&login))); + } + if !req.labels.is_empty() { + // ANDed, matching every other filter in this app. CNB's default is + // `contains_any`, so the operator has to be explicit. + url.push_str(&format!( + "&labels={}&labels_operator=contains_all", + urlencode_query(&req.labels.join(",")) + )); + } + if let Some(text) = req.search.as_deref() { + // Issues take a keyword filter; pulls have no equivalent (the + // parameter is ignored there, verified against the live API), so the + // panel hides the box on that tab rather than show one that does + // nothing — see `supportsSearch` in `forge-page.tsx`. + if req.tab == ForgeTab::Issues { + url.push_str(&format!("&keyword={}", urlencode_query(text))); + } + } + + let raw: Vec = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad list payload: {e}")))?; + + let is_pr = req.tab == ForgeTab::Prs; + // No total on this API. `has_next` from a full page is the honest signal + // available; an exact multiple costs one empty page, which the UI already + // renders as "no rows here". + let has_next = raw.len() as u32 >= per_page; + Ok(ForgeIssueList { + rows: raw + .into_iter() + .map(|item| item.into_row(auth, &repo, is_pr)) + .collect(), + page, + per_page, + total_count: None, + reachable_count: None, + has_next, + incomplete: false, + }) +} + +/// One issue, read in full. +/// +/// CNB's issue LIST omits `body` outright (its pull list carries one), so a +/// panel holding a row from the list has no description to draw. This is the +/// one request that fills it, paid only when the panel opens — the same trade +/// the comment thread makes. A pull never takes this path: its body rides on +/// the row. +pub async fn issue_detail( + auth: &ResolvedAuth, + owner_repo: &str, + number: i64, +) -> Result { + let repo = repo_ref(owner_repo)?; + let url = format!("{}/{repo}/-/issues/{number}", auth.api_base); + let raw: RawItem = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad issue payload: {e}")))?; + Ok(raw.into_row(auth, &repo, false)) +} + +/// Fill in the description of an ISSUE snapshot that came off a list row. +/// +/// A task's prompt wraps the snapshot it was created from, and on CNB both +/// places that build one — the trigger dialog and an auto-processing scan — +/// hold a list row, which never carries a body. Without this read every CNB +/// issue task would hand its agent a title and an empty fence: "handle issue +/// #N" with nothing to judge. Only a missing body is read; one the caller +/// already has (the panel read it, say) is left exactly as it is. +pub async fn fill_issue_body( + auth: &ResolvedAuth, + owner_repo: &str, + number: i64, + snapshot: &mut super::envelope::ForgeSnapshot, +) -> Result<(), ForgeError> { + if snapshot + .body + .as_deref() + .is_some_and(|body| !body.trim().is_empty()) + { + return Ok(()); + } + require_number(number)?; + snapshot.body = issue_detail(auth, owner_repo, number).await?.body; + Ok(()) +} + +/// Rows per scan page. CNB clamps `page_size` to 100. +const SCAN_PAGE_LIMIT: u32 = 100; + +/// Every OPEN item of the wanted kinds updated at or after `since` — what an +/// auto-processing scan reads — starting from where `from` says. +/// +/// CNB's `updated_time_begin` is a DATE (`2022-01-31`), not a timestamp, so +/// the window is widened to the containing day. Re-reading up to 24 hours of +/// items is safe: the trigger path deduplicates on the source key, while +/// narrowing the window would silently miss items. +pub async fn list_open_items( + auth: &ResolvedAuth, + owner_repo: &str, + issues: bool, + pulls: bool, + since: Option>, + from: &ScanCursor, +) -> Result { + let repo = repo_ref(owner_repo)?; + let mut rows = Vec::new(); + let mut cursor = ScanCursor::default(); + let since_day = since.map(|s| s.date_naive().to_string()); + + for (wanted, collection, is_pr) in [(issues, "issues", false), (pulls, "pulls", true)] { + if !wanted { + continue; + } + let repo = &repo; + let since_day = since_day.clone(); + super::scan_collection( + collection, + from, + &mut cursor, + &mut rows, + SCAN_MAX_PAGES, + |page| { + let mut url = format!( + "{}/{repo}/-/{collection}?state=open&page={page}&page_size={SCAN_PAGE_LIMIT}", + auth.api_base + ); + if let Some(day) = since_day.as_deref() { + url.push_str(&format!("&updated_time_begin={}", urlencode_query(day))); + } + async move { + let raw: Vec = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad list payload: {e}")))?; + let has_next = raw.len() as u32 >= SCAN_PAGE_LIMIT; + Ok(( + raw.into_iter() + .map(|item| item.into_row(auth, repo, is_pr)) + .collect(), + has_next, + )) + } + }, + ) + .await?; + } + Ok(OpenItemsScan { + rows, + resume: cursor.has_more().then_some(cursor), + }) +} + +/// The repository's labels, for the workbench's label filter. +pub async fn list_labels( + auth: &ResolvedAuth, + owner_repo: &str, +) -> Result { + let repo = repo_ref(owner_repo)?; + let url = format!( + "{}/{repo}/-/labels?page=1&page_size={LABEL_PAGE_SIZE}", + auth.api_base + ); + let raw: Vec = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad labels payload: {e}")))?; + let truncated = raw.len() >= LABEL_PAGE_SIZE; + Ok(ForgeLabelList { + labels: raw + .into_iter() + .filter_map(|l| ForgeLabel::parse(l.name, l.color.as_deref())) + .collect(), + truncated, + }) +} + +/// One page of an item's conversation. +/// +/// Issues and merge requests have their own comment collections, so unlike +/// GitHub/Gitea this one needs the item's kind. `has_next` comes from a full +/// page: CNB reports no total, and this is the only signal the API gives. +pub async fn list_comments( + auth: &ResolvedAuth, + owner_repo: &str, + kind: ForgeItemKind, + number: i64, + page: u32, + per_page: u32, +) -> Result { + let repo = repo_ref(owner_repo)?; + require_number(number)?; + let collection = collection_of_kind(kind); + let url = format!( + "{}/{repo}/-/{collection}/{number}/comments?page={page}&page_size={per_page}", + auth.api_base + ); + let raw: Vec = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad comments payload: {e}")))?; + let has_next = raw.len() as u32 >= per_page; + Ok(ForgeCommentList { + comments: raw.into_iter().map(RawComment::into_comment).collect(), + page, + per_page, + has_next, + }) +} + +// ── writes ────────────────────────────────────────────────────────────────── + +/// Post one comment on an issue or merge request. +pub async fn create_comment( + auth: &ResolvedAuth, + owner_repo: &str, + kind: ForgeItemKind, + number: i64, + body: &str, +) -> Result { + let repo = repo_ref(owner_repo)?; + require_number(number)?; + let collection = collection_of_kind(kind); + let url = format!("{}/{repo}/-/{collection}/{number}/comments", auth.api_base); + let raw: RawComment = api_post(auth, &url, &serde_json::json!({ "body": body })) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad comment payload: {e}")))?; + Ok(raw.into_comment()) +} + +/// Close or reopen one item. +/// +/// Issues take `state` + `state_reason` together (the API rejects one without +/// the other); pulls take `state` alone. +pub async fn set_item_state( + auth: &ResolvedAuth, + owner_repo: &str, + kind: ForgeItemKind, + number: i64, + action: ForgeStateAction, +) -> Result { + let repo = repo_ref(owner_repo)?; + require_number(number)?; + let collection = collection_of_kind(kind); + let url = format!("{}/{repo}/-/{collection}/{number}", auth.api_base); + let payload = match kind { + ForgeItemKind::Issue => serde_json::json!({ + "state": action.github_state(), + "state_reason": match action { + ForgeStateAction::Close => "completed", + ForgeStateAction::Reopen => "reopened", + }, + }), + ForgeItemKind::Change => serde_json::json!({ "state": action.github_state() }), + }; + let raw: RawItem = api_patch(auth, &url, &payload) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad item payload: {e}")))?; + Ok(raw.into_row(auth, &repo, kind == ForgeItemKind::Change)) +} + +/// `POST /{repo}/-/issues` — open an issue. +/// +/// CNB takes label NAMES on creation (unlike Gitea's ids), so the chips the +/// dialog sends are passed straight through. +pub async fn create_issue( + auth: &ResolvedAuth, + owner_repo: &str, + draft: &ResolvedNewIssue, +) -> Result { + let repo = repo_ref(owner_repo)?; + let url = format!("{}/{repo}/-/issues", auth.api_base); + let mut payload = serde_json::json!({ "title": draft.title }); + if let Some(body) = draft.body.as_deref() { + payload["body"] = serde_json::Value::String(body.to_string()); + } + if !draft.labels.is_empty() { + payload["labels"] = serde_json::Value::from(draft.labels.clone()); + } + let raw: RawItem = api_post(auth, &url, &payload) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad issue payload: {e}")))?; + Ok(raw.into_row(auth, &repo, false)) +} + +// ── proposed changes ──────────────────────────────────────────────────────── + +/// One merge request's branches, state and mergeability. +/// +/// CNB's `Pull` carries no diff counters and no commit count, so those stay +/// `None` rather than zero — see [`ForgeChangeDetail`]. Its `mergeable_state` +/// is a WORD (`checking`, `mergeable`, `merging`, `merged`, `conflict`, +/// `no-merge-base`), and `checking` is a real third answer: the platform is +/// still computing it, so "we do not know yet" is what the panel should say. +pub async fn change_detail( + auth: &ResolvedAuth, + owner_repo: &str, + number: i64, +) -> Result { + let repo = repo_ref(owner_repo)?; + require_number(number)?; + let url = format!("{}/{repo}/-/pulls/{number}", auth.api_base); + let raw: RawPull = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad pull payload: {e}")))?; + + let head_sha = raw + .head + .as_ref() + .and_then(|h| h.sha.clone()) + .filter(|sha| !sha.is_empty()); + // Shown only when the head is somebody ELSE's repository — a fork is the + // fact worth a line. + let head_repo = raw + .head + .as_ref() + .and_then(|h| h.repo.as_ref()) + .and_then(|r| r.path.clone().or_else(|| r.name.clone())) + .filter(|path| !super::same_repo(path, &repo)); + + Ok(ForgeChangeDetail { + number: raw.number(), + base_ref: raw.base.as_ref().map(|b| b.branch()).unwrap_or_default(), + head_ref: raw.head.as_ref().map(|h| h.branch()).unwrap_or_default(), + head_repo, + head_sha, + draft: raw.is_wip, + state: pull_state(&raw.state, raw.is_merged), + mergeable: mergeable(&raw.mergeable_state), + merge_state: raw.mergeable_state.clone(), + additions: None, + deletions: None, + changed_files: None, + commits: None, + // CNB groups checks under a `statuses` map whose shape is not the + // commit-status list the other clients fold into `ForgeCheck`; saying + // "unavailable" is honest, and the panel draws no strip. + checks: ForgeCheckList::unavailable(), + }) +} + +/// One page of a merge request's files, cut locally: CNB's file endpoint takes +/// no page parameters and answers with the whole list. +pub async fn list_change_files( + auth: &ResolvedAuth, + owner_repo: &str, + number: i64, + page: u32, + per_page: u32, +) -> Result { + let repo = repo_ref(owner_repo)?; + require_number(number)?; + let url = format!("{}/{repo}/-/pulls/{number}/files", auth.api_base); + let raw: Vec = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad files payload: {e}")))?; + + let skip = (page as usize) + .saturating_sub(1) + .saturating_mul(per_page as usize); + let total = raw.len(); + Ok(ForgeChangedFileList { + files: raw + .into_iter() + .skip(skip) + .take(per_page as usize) + .map(RawFile::into_file) + .collect(), + page, + per_page, + has_next: total > skip.saturating_add(per_page as usize), + }) +} + +/// CNB publishes no repository-settings endpoint for merge styles, so the menu +/// is the conservative one: a merge commit. The platform still has the last +/// word and refuses what the repository forbids. +pub async fn merge_options( + _auth: &ResolvedAuth, + _owner_repo: &str, +) -> Result { + Ok(ForgeMergeOptions::unknown()) +} + +/// Land one merge request, and hand back the row as CNB now sees it. +/// +/// The head commit is checked first when the caller named one: CNB's merge +/// endpoint takes no head SHA, so an unguarded merge could land a commit the +/// reviewer never saw. Past the merge nothing may return `Err` — the change +/// HAS landed, and `Ok(None)` is the honest answer when the read-back fails. +pub async fn merge_change( + auth: &ResolvedAuth, + owner_repo: &str, + number: i64, + method: ForgeMergeMethod, + head_sha: Option<&str>, +) -> Result, ForgeError> { + let repo = repo_ref(owner_repo)?; + require_number(number)?; + + if let Some(expected) = head_sha { + let url = format!("{}/{repo}/-/pulls/{number}", auth.api_base); + let current: RawPull = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad pull payload: {e}")))?; + let actual = current + .head + .as_ref() + .and_then(|h| h.sha.as_deref()) + .unwrap_or_default(); + if !actual.eq_ignore_ascii_case(expected) { + return Err(ForgeError::Api { + status: 409, + message: format!( + "the merge request moved: expected head {expected}, found {actual}" + ), + }); + } + } + + let url = format!("{}/{repo}/-/pulls/{number}/merge", auth.api_base); + let payload = serde_json::json!({ "merge_style": merge_style(method) }); + api_put(auth, &url, &payload).await?; + + // Past this line the change HAS landed, so nothing below may return `Err`. + // The merge endpoint answers with `{merged, message, sha}` — not a row — + // and re-reading the pull would still not produce one (CNB's pull payload + // has no labels/author in the shape a row needs). `Ok(None)` is the honest + // answer: the merge succeeded, the list will catch up. + Ok(None) +} + +/// CNB's `merge_style` values. `fast_forward` has no counterpart in +/// [`ForgeMergeMethod`], which is fine: the panel never offers it here. +fn merge_style(method: ForgeMergeMethod) -> &'static str { + match method { + ForgeMergeMethod::Merge => "merge", + ForgeMergeMethod::Squash => "squash", + ForgeMergeMethod::Rebase => "rebase", + } +} + +// ── delivery ──────────────────────────────────────────────────────────────── + +/// How far back [`find_pulls`] looks for a merge request headed by one branch. +/// Bounded for the same reason the other clients bound it: this is recovery +/// for a branch this delivery just pushed, which is at the front of the list. +const PR_SCAN_PAGES: u32 = 5; +/// Rows per scan page. CNB clamps to 100. +const PR_SCAN_PAGE_SIZE: u32 = 50; + +/// Merge requests whose head is `head_branch`, in ANY state — a merged or +/// closed one is exactly what recovery must be able to see. +/// +/// CNB's `/pulls` takes no head-branch filter, so the scan is local, newest +/// first. It fails SAFE: an unseen merge request reads as "no match", and the +/// create that follows is refused by CNB rather than duplicating anything. +pub async fn find_pulls( + auth: &ResolvedAuth, + owner_repo: &str, + head_branch: &str, +) -> Result, ForgeError> { + let repo = repo_ref(owner_repo)?; + let mut found = Vec::new(); + for page in 1..=PR_SCAN_PAGES { + let url = format!( + "{}/{repo}/-/pulls?state=all&page={page}&page_size={PR_SCAN_PAGE_SIZE}", + auth.api_base + ); + let raw: Vec = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad pulls payload: {e}")))?; + let full = raw.len() as u32 >= PR_SCAN_PAGE_SIZE; + found.extend( + raw.into_iter() + .filter(|pull| { + pull.head + .as_ref() + .is_some_and(|h| h.branch() == head_branch) + }) + .map(|pull| pull.into_pr(&repo, &web_origin(auth))), + ); + if !full { + break; + } + } + Ok(found) +} + +/// `GET /{repo}/-/pulls/{n}` — one merge request by number. +pub async fn get_pull( + auth: &ResolvedAuth, + owner_repo: &str, + number: i64, +) -> Result { + let repo = repo_ref(owner_repo)?; + require_number(number)?; + let url = format!("{}/{repo}/-/pulls/{number}", auth.api_base); + let raw: RawPull = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad pull payload: {e}")))?; + Ok(raw.into_pr(&repo, &web_origin(auth))) +} + +/// `POST /{repo}/-/pulls`. +/// +/// CNB has no `draft` parameter; a draft is a title carrying a work-in-progress +/// prefix. `WIP:` is the conventional spelling, and what CNB's own UI shows, so +/// a draft is opened by prefixing — the same trick Gitea and GitLab need. +pub async fn create_pull( + auth: &ResolvedAuth, + owner_repo: &str, + req: &NewPullRequest<'_>, +) -> Result { + let repo = repo_ref(owner_repo)?; + let url = format!("{}/{repo}/-/pulls", auth.api_base); + let title = if req.draft && !is_wip_title(req.title) { + format!("WIP: {}", req.title) + } else { + req.title.to_string() + }; + let body = serde_json::json!({ + "head": req.head, + "base": req.base, + "title": title, + "body": req.body, + }); + let raw: RawPull = api_post(auth, &url, &body) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad pull payload: {e}")))?; + Ok(raw.into_pr(&repo, &web_origin(auth))) +} + +/// Whether a title already declares itself a draft, so it is not prefixed +/// twice. `get(..n)` rather than a byte slice: a title is arbitrary UTF-8. +fn is_wip_title(title: &str) -> bool { + let trimmed = title.trim_start(); + ["wip:", "[wip]"].iter().any(|prefix| { + trimmed + .get(..prefix.len()) + .is_some_and(|got| got.eq_ignore_ascii_case(prefix)) + }) +} + +// ── plumbing ──────────────────────────────────────────────────────────────── + +/// `GET {api_base}/user` → username, cached per `(api_base, account)`. +/// Resolving it on every "assigned to me" page would spend a request per click. +static LOGIN_CACHE: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +async fn current_login(auth: &ResolvedAuth) -> Result { + let key = format!("{}|{}", auth.api_base, auth.account_id); + if let Ok(guard) = LOGIN_CACHE.read() { + if let Some(login) = guard.get(&key) { + return Ok(login.clone()); + } + } + let url = format!("{}/user", auth.api_base); + let raw: RawUser = api_get(auth, &url) + .await? + .json() + .await + .map_err(|e| ForgeError::Network(format!("bad user payload: {e}")))?; + let login = raw + .username + .clone() + .filter(|name| !name.trim().is_empty()) + .or_else(|| raw.nickname.clone().filter(|name| !name.trim().is_empty())) + .ok_or_else(|| ForgeError::Auth("CNB answered without a username".to_string()))?; + if let Ok(mut guard) = LOGIN_CACHE.write() { + guard.insert(key, login.clone()); + } + Ok(login) +} + +/// `owner/repo`, validated. CNB addresses a repository with two path segments, +/// like GitHub — the slash is part of the route, so it is NOT percent-encoded. +fn repo_ref(owner_repo: &str) -> Result { + normalize_repo(owner_repo) + .ok_or_else(|| ForgeError::Invalid(format!("bad repository path: {owner_repo}"))) +} + +/// `None` for an absent or blank string — CNB's spelling of "no time". +fn non_empty(value: Option) -> Option { + value.filter(|v| !v.trim().is_empty()) +} + +fn require_number(number: i64) -> Result<(), ForgeError> { + if number <= 0 { + return Err(ForgeError::Invalid(format!( + "bad work item number: {number}" + ))); + } + Ok(()) +} + +/// The workbench's order as CNB's `order_by`. +/// +/// CNB accepts `created_at`, `-created_at`, `-updated_at` (and, on issues only, +/// `-last_acted_at`) — there is NO ascending updated order, so +/// [`ForgeSort::LeastRecentlyUpdated`] has no honest spelling. The panel does +/// not offer that order for CNB; should it ever arrive anyway, the default +/// newest-first is used rather than an order the server would reject. +fn order_by(sort: ForgeSort) -> &'static str { + match sort { + ForgeSort::Oldest => "created_at", + ForgeSort::RecentlyUpdated => "-updated_at", + ForgeSort::Newest | ForgeSort::LeastRecentlyUpdated => "-created_at", + } +} + +/// How a collection spells "every state". +/// +/// The two collections do NOT agree, which is a trap worth stating plainly: +/// the issue list documents only `open`/`closed` — leaving the filter out is +/// what returns everything — while the pull list takes an explicit `all` and +/// treats an omitted filter as open-only. Sending the issue spelling to pulls +/// made the panel's "全部" show nothing on a repository whose merge requests +/// had all landed. +fn state_filter(tab: ForgeTab, state: &str) -> Option<&'static str> { + match state { + "all" if tab == ForgeTab::Prs => Some("all"), + "all" => None, + "open" => Some("open"), + "closed" => Some("closed"), + // `validate_state_filter` rejects anything else before this point. + _ => None, + } +} + +fn collection_of(tab: ForgeTab) -> &'static str { + match tab { + ForgeTab::Issues => "issues", + ForgeTab::Prs => "pulls", + } +} + +fn collection_of_kind(kind: ForgeItemKind) -> &'static str { + match kind { + ForgeItemKind::Issue => "issues", + ForgeItemKind::Change => "pulls", + } +} + +/// Normalize CNB's pull state to the workbench's three words. +/// +/// The flag is checked FIRST, not last: a merged merge request arrives as +/// `state: "closed"` with `is_merged: true` (verified against the live API), so +/// a match on `state` alone reports every landed change as merely closed. +fn pull_state(state: &str, merged: bool) -> String { + if merged { + return "merged".to_string(); + } + match state.trim().to_ascii_lowercase().as_str() { + "merged" => "merged".to_string(), + "closed" => "closed".to_string(), + _ => "open".to_string(), + } +} + +/// CNB's `mergeable_state` as the panel's tri-state. +fn mergeable(raw: &Option) -> Option { + match raw.as_deref().map(str::trim).unwrap_or("") { + "mergeable" | "merged" => Some(true), + "conflict" | "no-merge-base" => Some(false), + // `checking` and `merging` are both "not answerable yet" — the first + // is a computation in flight, the second a merge already landing. + _ => None, + } +} + +pub(crate) async fn api_get( + auth: &ResolvedAuth, + url: &str, +) -> Result { + send(super::http_client()?.get(url), auth).await +} + +async fn api_post( + auth: &ResolvedAuth, + url: &str, + body: &serde_json::Value, +) -> Result { + send(super::http_client()?.post(url).json(body), auth).await +} + +async fn api_patch( + auth: &ResolvedAuth, + url: &str, + body: &serde_json::Value, +) -> Result { + send(super::http_client()?.patch(url).json(body), auth).await +} + +async fn api_put( + auth: &ResolvedAuth, + url: &str, + body: &serde_json::Value, +) -> Result { + send(super::http_client()?.put(url).json(body), auth).await +} + +/// The headers every CNB request shares. `Bearer`, not `token`. +async fn send( + request: reqwest::RequestBuilder, + auth: &ResolvedAuth, +) -> Result { + let response = request + .header("Authorization", format!("Bearer {}", auth.token)) + .header("User-Agent", "codeg") + .header("Accept", "application/json") + .send() + .await + .map_err(|e| ForgeError::Network(e.to_string()))?; + finish(response).await +} + +/// Success through, everything else classified. +/// +/// CNB's error body is `{"errcode":N,"errmsg":"…"}`; the message is the only +/// place it says which permission a token is missing, so it is carried into +/// [`ForgeError::Api`] instead of being replaced by the status. +async fn finish(response: reqwest::Response) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status().as_u16(); + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + match status { + 429 => Err(ForgeError::RateLimited { retry_after }), + // 403 is usually a SCOPE problem, not a bad token: CNB's `errmsg` says + // exactly which permission is missing, and that is the one sentence + // that lets the user fix it, so it travels with the error. + 401 | 403 => { + let text = response.text().await.unwrap_or_default(); + let detail = serde_json::from_str::(&text) + .ok() + .and_then(|e| e.errmsg) + .filter(|msg| !msg.trim().is_empty()); + Err(ForgeError::Auth(match detail { + Some(msg) => format!("CNB returned {status}: {msg}"), + None => format!("CNB returned {status}"), + })) + } + 404 => Err(ForgeError::NotFound), + _ => { + let text = response.text().await.unwrap_or_default(); + let message = serde_json::from_str::(&text) + .ok() + .and_then(|e| e.errmsg) + .filter(|msg| !msg.trim().is_empty()) + .unwrap_or_else(|| truncate_chars(&text, 300)); + Err(ForgeError::Api { status, message }) + } + } +} + +// ── wire shapes ───────────────────────────────────────────────────────────── + +/// One issue or merge request in the list. Both shapes deserialize into this: +/// the fields they share are the row, and `body`/`is_wip` are simply absent on +/// an issue (CNB's issue list omits the body entirely). +#[derive(Debug, Deserialize)] +struct RawItem { + /// A STRING on the wire for both collections. + #[serde(default)] + number: String, + #[serde(default)] + title: String, + #[serde(default)] + body: Option, + #[serde(default)] + state: String, + #[serde(default)] + is_wip: bool, + #[serde(default)] + is_merged: bool, + #[serde(default)] + labels: Vec, + #[serde(default)] + author: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + last_acted_at: Option, + #[serde(default)] + comment_count: i64, +} + +impl RawItem { + fn number(&self) -> i64 { + self.number.trim().parse().unwrap_or(0) + } + + fn into_row(self, auth: &ResolvedAuth, repo: &str, is_pr: bool) -> ForgeIssueRow { + let number = self.number(); + let (author, author_avatar) = self + .author + .map(|u| { + ( + ForgeComment::author_name(u.display_name()), + u.avatar.as_deref().and_then(sanitize_web_url), + ) + }) + .unwrap_or((None, None)); + let collection = if is_pr { "pulls" } else { "issues" }; + ForgeIssueRow { + number, + title: self.title, + body: self + .body + .as_deref() + .map(|b| truncate_chars(b, BODY_CAP)) + .filter(|b| !b.trim().is_empty()), + state: pull_state(&self.state, self.is_merged), + draft: self.is_wip, + labels: self + .labels + .into_iter() + .filter_map(|l| ForgeLabel::parse(l.name, l.color.as_deref())) + .collect(), + author, + author_avatar, + // CNB spells an unset time as `""` (see `closed_at` on a live + // row), and an empty `last_acted_at` must not shadow a real + // `updated_at` — nor reach the panel as a date it cannot read. + updated_at: non_empty(self.last_acted_at).or(non_empty(self.updated_at)), + html_url: format!("{}/{repo}/-/{collection}/{number}", web_origin(auth)), + is_pr, + comments: self.comment_count, + } + } +} + +/// A merge request's detail payload. Shared with the list for the fields the +/// list also carries; `head`/`base`/`mergeable_state` are detail-only. +#[derive(Debug, Deserialize)] +struct RawPull { + #[serde(default)] + number: String, + #[serde(default)] + state: String, + #[serde(default)] + is_wip: bool, + #[serde(default)] + is_merged: bool, + #[serde(default)] + mergeable_state: Option, + #[serde(default)] + head: Option, + #[serde(default)] + base: Option, +} + +impl RawPull { + fn number(&self) -> i64 { + self.number.trim().parse().unwrap_or(0) + } + + fn into_pr(self, repo: &str, origin: &str) -> ForgePr { + let head = self.head.as_ref(); + ForgePr { + number: self.number(), + // The web URL is built from the repository rather than read off + // the payload: CNB's Pull carries no `html_url`. + html_url: format!( + "{}/{repo}/-/pulls/{}", + origin.trim_end_matches('/'), + self.number() + ), + state: pull_state(&self.state, self.is_merged), + merged: self.is_merged || self.state.eq_ignore_ascii_case("merged"), + head_sha: head.and_then(|h| h.sha.clone()).unwrap_or_default(), + head_ref: head.map(|h| h.branch()).unwrap_or_default(), + head_repo: head + .and_then(|h| h.repo.as_ref()) + .and_then(|r| r.path.clone().or_else(|| r.name.clone())) + .unwrap_or_else(|| repo.to_string()), + base_ref: self.base.map(|b| b.branch()).unwrap_or_default(), + } + } +} + +#[derive(Debug, Deserialize)] +struct RawPullRef { + /// `refs/heads/main`, per CNB's schema — [`Self::branch`] strips the + /// namespace, because every consumer wants the branch NAME. + #[serde(default, rename = "ref")] + ref_name: String, + #[serde(default)] + sha: Option, + #[serde(default)] + repo: Option, +} + +impl RawPullRef { + fn branch(&self) -> String { + self.ref_name + .strip_prefix("refs/heads/") + .unwrap_or(&self.ref_name) + .to_string() + } +} + +#[derive(Debug, Deserialize)] +struct RawRepo { + #[serde(default)] + name: Option, + #[serde(default)] + path: Option, +} + +#[derive(Debug, Deserialize)] +struct RawUser { + #[serde(default)] + username: Option, + #[serde(default)] + nickname: Option, + #[serde(default)] + avatar: Option, +} + +impl RawUser { + fn display_name(&self) -> Option { + self.username + .clone() + .filter(|n| !n.trim().is_empty()) + .or_else(|| self.nickname.clone().filter(|n| !n.trim().is_empty())) + } +} + +#[derive(Debug, Deserialize)] +struct RawLabel { + #[serde(default)] + name: String, + #[serde(default)] + color: Option, +} + +#[derive(Debug, Deserialize)] +struct RawComment { + #[serde(default)] + id: String, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + author: Option, +} + +impl RawComment { + fn into_comment(self) -> ForgeComment { + let (author, author_avatar) = self + .author + .map(|u| { + ( + ForgeComment::author_name(u.display_name()), + u.avatar.as_deref().and_then(sanitize_web_url), + ) + }) + .unwrap_or((None, None)); + let created_at = non_empty(self.created_at); + ForgeComment { + id: self.id, + author, + author_avatar, + body: truncate_chars(self.body.as_deref().unwrap_or_default(), BODY_CAP), + updated_at: ForgeComment::edited_at(created_at.as_deref(), non_empty(self.updated_at)), + created_at, + // CNB sends no per-comment URL and no anchor is guaranteed to + // survive its SPA routing; no link beats a link that 404s. + html_url: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct RawFile { + /// The BASE NAME only (`3-experience-report.yml`) — CNB does not send the + /// repository path here. [`Self::path`] recovers it from the URLs. + #[serde(default)] + filename: String, + #[serde(default)] + blob_url: Option, + #[serde(default)] + raw_url: Option, + #[serde(default)] + status: String, + #[serde(default)] + additions: i64, + #[serde(default)] + deletions: i64, + #[serde(default)] + patch: Option, +} + +impl RawFile { + /// The file's path inside the repository. + /// + /// `filename` is only the base name (verified against the live API: a file + /// at `.cnb/ISSUE_TEMPLATE/3-experience-report.yml` arrives as + /// `3-experience-report.yml`), and a diff list of bare names cannot tell + /// two same-named files apart. The path is present in `blob_url` / + /// `raw_url` after the commit SHA, so it is read from there; the bare name + /// stays as the fallback for a payload that carries neither. + fn path(&self) -> String { + const ANCHORS: [&str; 2] = ["/blob/", "/git/raw/"]; + for url in [self.blob_url.as_deref(), self.raw_url.as_deref()] + .into_iter() + .flatten() + { + for anchor in ANCHORS { + let Some((_, rest)) = url.split_once(anchor) else { + continue; + }; + // `rest` is `/`; the first segment is the commit. + let Some((_sha, path)) = rest.split_once('/') else { + continue; + }; + if !path.is_empty() { + return path.to_string(); + } + } + } + self.filename.clone() + } + + fn into_file(self) -> ForgeChangedFile { + let status = match self.status.trim().to_ascii_lowercase().as_str() { + "add" | "added" => ForgeFileStatus::Added, + "delete" | "deleted" | "remove" | "removed" => ForgeFileStatus::Removed, + "rename" | "renamed" => ForgeFileStatus::Renamed, + // `copy` has no variant of its own; it is a file that exists at a + // path it did not before, which is what `Added` means here. + "copy" | "copied" => ForgeFileStatus::Added, + _ => ForgeFileStatus::Modified, + }; + let binary = self.patch.as_deref().is_none_or(str::is_empty); + let path = self.path(); + ForgeChangedFile { + path, + previous_path: None, + status, + // CNB reports counters per file, so absence really is zero here. + additions: Some(self.additions), + deletions: Some(self.deletions), + binary, + patch: self.patch.filter(|p| !p.is_empty()), + } + } +} + +#[derive(Debug, Deserialize)] +struct RawError { + #[serde(default)] + errmsg: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real issue row, copied from `GET /cnb/feedback/-/issues`. The fields + /// worth pinning are the STRING number and the absent body. + #[test] + fn issue_row_deserializes_without_a_body() { + let raw: RawItem = serde_json::from_value(serde_json::json!({ + "number": "5330", + "state": "open", + "state_reason": "open", + "title": "申请为组织开通模型", + "invisible": false, + "author": { "username": "cnb.abc", "nickname": "竹影清风" }, + "assignees": [], + "labels": [], + "comment_count": 7, + "created_at": "2026-10-29T05:53:03Z", + "updated_at": "2026-10-29T12:06:18Z", + "closed_at": "" + })) + .expect("issue row"); + assert_eq!(raw.number(), 5330); + assert_eq!(raw.body, None); + assert_eq!(raw.comment_count, 7); + assert_eq!(raw.state, "open"); + } + + /// A real pull row: the refs carry the `refs/heads/` namespace and the + /// state is `merged` outright. + #[test] + fn pull_row_strips_ref_namespace_and_reads_merged_state() { + let raw: RawPull = serde_json::from_value(serde_json::json!({ + "number": "5255", + "state": "closed", + "mergeable_state": "merged", + "is_wip": false, + "is_merged": true, + "head": { + "ref": "refs/heads/auto/fix", + "sha": "abc123", + "repo": { "name": "feedback", "path": "cnb/feedback" } + }, + "base": { + "ref": "refs/heads/main", + "sha": "def456", + "repo": { "name": "feedback", "path": "cnb/feedback" } + } + })) + .expect("pull row"); + let pr = raw.into_pr("cnb/feedback", "https://cnb.cool"); + assert_eq!(pr.number, 5255); + assert_eq!(pr.head_ref, "auto/fix"); + assert_eq!(pr.base_ref, "main"); + assert_eq!(pr.head_sha, "abc123"); + assert_eq!(pr.state, "merged"); + assert!(pr.merged); + assert_eq!(pr.html_url, "https://cnb.cool/cnb/feedback/-/pulls/5255"); + } + + #[test] + fn order_by_uses_only_the_spellings_cnb_accepts() { + assert_eq!(order_by(ForgeSort::Newest), "-created_at"); + assert_eq!(order_by(ForgeSort::Oldest), "created_at"); + assert_eq!(order_by(ForgeSort::RecentlyUpdated), "-updated_at"); + // No ascending-updated order exists; the panel does not offer this one + // for CNB, and the default is the safe landing if it ever arrives. + assert_eq!(order_by(ForgeSort::LeastRecentlyUpdated), "-created_at"); + } + + #[test] + fn all_is_spelled_per_collection() { + // Issues: omitting the filter is the only way to ask for everything. + assert_eq!(state_filter(ForgeTab::Issues, "all"), None); + // Pulls: an explicit `all`; omitting it means open-only. + assert_eq!(state_filter(ForgeTab::Prs, "all"), Some("all")); + assert_eq!(state_filter(ForgeTab::Issues, "open"), Some("open")); + assert_eq!(state_filter(ForgeTab::Prs, "closed"), Some("closed")); + } + + /// A merged merge request comes back CLOSED with the flag set, not with a + /// `merged` state — the flag has to win, or every landed change reads as + /// merely closed. + #[test] + fn merged_flag_beats_the_closed_state() { + assert_eq!(pull_state("closed", true), "merged"); + assert_eq!(pull_state("closed", false), "closed"); + assert_eq!(pull_state("open", false), "open"); + assert_eq!(pull_state("merged", false), "merged"); + } + + #[test] + fn mergeable_state_maps_the_whole_vocabulary() { + assert_eq!(mergeable(&Some("mergeable".into())), Some(true)); + assert_eq!(mergeable(&Some("merged".into())), Some(true)); + assert_eq!(mergeable(&Some("conflict".into())), Some(false)); + assert_eq!(mergeable(&Some("no-merge-base".into())), Some(false)); + // Still being computed, or already landing: no answer yet. + assert_eq!(mergeable(&Some("checking".into())), None); + assert_eq!(mergeable(&Some("merging".into())), None); + assert_eq!(mergeable(&None), None); + } + + /// `filename` is a BASE NAME on CNB; the path is recovered from the URLs. + #[test] + fn file_path_comes_from_the_url_not_the_bare_name() { + let file: RawFile = serde_json::from_value(serde_json::json!({ + "filename": "3-experience-report.yml", + "status": "modify", + "additions": 5, + "deletions": 0, + "blob_url": "https://cnb.cool/cnb/feedback/-/blob/539e255/.cnb/ISSUE_TEMPLATE/3-experience-report.yml", + "raw_url": "https://cnb.cool/cnb/feedback/-/git/raw/539e255/.cnb/ISSUE_TEMPLATE/3-experience-report.yml", + "patch": "@@ -1 +1 @@" + })) + .expect("file row"); + assert_eq!(file.path(), ".cnb/ISSUE_TEMPLATE/3-experience-report.yml"); + + let bare: RawFile = serde_json::from_value(serde_json::json!({ + "filename": "README.md", + "status": "add" + })) + .expect("bare file row"); + assert_eq!(bare.path(), "README.md"); + } + + /// Live smoke test against the real CNB API — the only way to catch a + /// wire-shape drift the fixture rows above cannot predict. Ignored so CI + /// never touches the network; run it by hand: + /// + /// ```text + /// CNB_TEST_TOKEN=… CNB_TEST_REPO=cnb/feedback \ + /// cargo test --features test-utils forge::cnb::tests::live -- --ignored + /// ``` + #[tokio::test] + #[ignore = "network: needs CNB_TEST_TOKEN (and optionally CNB_TEST_REPO)"] + async fn live_smoke() { + let token = std::env::var("CNB_TEST_TOKEN").expect("CNB_TEST_TOKEN"); + let repo = std::env::var("CNB_TEST_REPO").unwrap_or_else(|_| "cnb/feedback".into()); + let auth = ResolvedAuth { + provider: super::super::ForgeProvider::Cnb, + server_host: "cnb.cool".into(), + api_base: "https://api.cnb.cool".into(), + account_id: "cnb-live".into(), + username: "live".into(), + avatar_url: None, + token, + scopes: vec![], + }; + + let list = list_issues( + &auth, + &issue_request(&repo, ForgeTab::Issues, ForgeSort::Newest), + ) + .await + .expect("issue list"); + assert!(!list.rows.is_empty(), "repo has no issues to read"); + assert!(list.rows.iter().all(|r| r.number > 0 && !r.is_pr)); + + let number = list.rows[0].number; + list_comments(&auth, &repo, ForgeItemKind::Issue, number, 1, 10) + .await + .expect("issue comments"); + list_labels(&auth, &repo).await.expect("labels"); + list_issues( + &auth, + &issue_request(&repo, ForgeTab::Issues, ForgeSort::Oldest), + ) + .await + .expect("oldest order"); + + let pulls = list_issues( + &auth, + &issue_request(&repo, ForgeTab::Prs, ForgeSort::Newest), + ) + .await + .expect("pull list"); + if let Some(pr) = pulls.rows.first() { + let detail = change_detail(&auth, &repo, pr.number) + .await + .expect("pull detail"); + assert_eq!(detail.number, pr.number); + list_change_files(&auth, &repo, pr.number, 1, 10) + .await + .expect("pull files"); + } + } + + fn issue_request(repo: &str, tab: ForgeTab, sort: ForgeSort) -> ListIssuesRequest { + ListIssuesRequest::new( + repo.to_string(), + super::super::ListFilters { + tab, + state: "all".into(), + assigned_me: false, + labels: vec![], + search: None, + sort, + page: 1, + per_page: 10, + account_id: None, + }, + ) + } + + fn mock_auth(api_base: String) -> ResolvedAuth { + ResolvedAuth { + provider: super::super::ForgeProvider::Cnb, + server_host: "cnb.example".into(), + api_base, + account_id: "cnb-mock".into(), + username: "alice".into(), + avatar_url: None, + token: "t0ken".into(), + scopes: vec![], + } + } + + /// `GET /acme/app/-/issues/7` answering an issue DETAIL — the one payload + /// that carries the body — and counting how often it was asked, with which + /// credential. + async fn mock_issue_detail() -> ( + String, + std::sync::Arc, + std::sync::Arc>>, + ) { + use axum::http::HeaderMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + let hits = Arc::new(AtomicUsize::new(0)); + let seen_auth = Arc::new(Mutex::new(None)); + let app = axum::Router::new().route("/acme/app/-/issues/7", { + let hits = hits.clone(); + let seen_auth = seen_auth.clone(); + axum::routing::get(move |headers: HeaderMap| async move { + hits.fetch_add(1, Ordering::SeqCst); + *seen_auth.lock().unwrap() = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + axum::Json(serde_json::json!({ + "number": "7", + "title": "Crash on start", + "body": "Steps:\n1. open the app", + "state": "open", + "labels": [], + "comment_count": 0, + "updated_at": "2026-10-29T12:06:18Z", + "last_acted_at": "" + })) + }) + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), hits, seen_auth) + } + + fn list_snapshot(body: Option<&str>) -> super::super::envelope::ForgeSnapshot { + super::super::envelope::ForgeSnapshot { + title: "Crash on start".into(), + body: body.map(str::to_string), + labels: vec![], + author: None, + } + } + + /// A task built from a CNB list row would otherwise hand its agent a title + /// and an empty fence: the list never carries the body. + #[tokio::test] + async fn a_list_snapshot_gets_the_issue_body_read_in() { + let (api_base, hits, seen_auth) = mock_issue_detail().await; + let auth = mock_auth(api_base); + + let mut snapshot = list_snapshot(None); + fill_issue_body(&auth, "acme/app", 7, &mut snapshot) + .await + .expect("hydrated"); + assert_eq!(snapshot.body.as_deref(), Some("Steps:\n1. open the app")); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(seen_auth.lock().unwrap().as_deref(), Some("Bearer t0ken")); + + // A blank body is the list's "nothing", not an answer. + let mut blank = list_snapshot(Some(" ")); + fill_issue_body(&auth, "acme/app", 7, &mut blank) + .await + .expect("hydrated"); + assert_eq!(blank.body.as_deref(), Some("Steps:\n1. open the app")); + } + + /// A body the caller already holds is never re-read or replaced. + #[tokio::test] + async fn a_snapshot_that_has_a_body_costs_no_request() { + let (api_base, hits, _) = mock_issue_detail().await; + let auth = mock_auth(api_base); + + let mut snapshot = list_snapshot(Some("already here")); + fill_issue_body(&auth, "acme/app", 7, &mut snapshot) + .await + .expect("untouched"); + assert_eq!(snapshot.body.as_deref(), Some("already here")); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + /// CNB spells an unset time `""`; an empty `last_acted_at` must fall back + /// to `updated_at` rather than reach the panel as an unreadable date. + #[tokio::test] + async fn an_empty_activity_time_falls_back_to_updated_at() { + let (api_base, _, _) = mock_issue_detail().await; + let auth = mock_auth(api_base); + let row = issue_detail(&auth, "acme/app", 7).await.expect("detail"); + assert_eq!(row.updated_at.as_deref(), Some("2026-10-29T12:06:18Z")); + assert_eq!(row.body.as_deref(), Some("Steps:\n1. open the app")); + } + + #[test] + fn file_status_vocabulary_maps_to_the_panel() { + let status = |s: &str| { + let file: RawFile = serde_json::from_value(serde_json::json!({ + "filename": "f", + "status": s + })) + .expect("file row"); + file.into_file().status + }; + assert_eq!(status("add"), ForgeFileStatus::Added); + assert_eq!(status("delete"), ForgeFileStatus::Removed); + assert_eq!(status("rename"), ForgeFileStatus::Renamed); + assert_eq!(status("modify"), ForgeFileStatus::Modified); + } +} diff --git a/src-tauri/src/forge/deliver.rs b/src-tauri/src/forge/deliver.rs index 25cbeb86787..32e9d31f526 100644 --- a/src-tauri/src/forge/deliver.rs +++ b/src-tauri/src/forge/deliver.rs @@ -29,8 +29,8 @@ use serde::Deserialize; use super::auth::ResolvedAuth; use super::{ - gitea, github, gitlab, urlencode_query, web_origin, DeliveryTarget, ForgeError, ForgeItemKind, - ForgeProvider, + cnb, gitea, github, gitlab, urlencode_query, web_origin, DeliveryTarget, ForgeError, + ForgeItemKind, ForgeProvider, }; use crate::app_error::AppCommandError; @@ -373,6 +373,7 @@ impl ForgeDeliveryApi for ForgeDelivery { ForgeProvider::Gitea => { gitea::find_pulls(&auth, ctx.owner_repo, head_branch).await } + ForgeProvider::Cnb => cnb::find_pulls(&auth, ctx.owner_repo, head_branch).await, } .map_err(|e| e.to_string()) } @@ -389,6 +390,7 @@ impl ForgeDeliveryApi for ForgeDelivery { gitlab::create_merge_request(&auth, ctx.owner_repo, req).await } ForgeProvider::Gitea => gitea::create_pull(&auth, ctx.owner_repo, req).await, + ForgeProvider::Cnb => cnb::create_pull(&auth, ctx.owner_repo, req).await, } .map_err(|e| e.to_string()) } @@ -401,6 +403,7 @@ impl ForgeDeliveryApi for ForgeDelivery { gitlab::get_merge_request(&auth, ctx.owner_repo, number).await } ForgeProvider::Gitea => gitea::get_pull(&auth, ctx.owner_repo, number).await, + ForgeProvider::Cnb => cnb::get_pull(&auth, ctx.owner_repo, number).await, } .map_err(|e| e.to_string()) } @@ -417,7 +420,7 @@ impl ForgeDeliveryApi for ForgeDelivery { .await .map_err(|e| e.message)? .ok_or_else(|| { - "this project has no `origin` remote on GitHub, GitLab or Gitea to deliver to" + "this project has no `origin` remote on GitHub, GitLab, Gitea or CNB to deliver to" .to_string() })?; if !remote.supported { @@ -487,6 +490,9 @@ impl ForgeDeliveryApi for ForgeDelivery { ForgeProvider::Gitea => gitea::create_comment(&auth, ctx.owner_repo, number, body) .await .map(|comment| comment.html_url.unwrap_or_default()), + ForgeProvider::Cnb => cnb::create_comment(&auth, ctx.owner_repo, kind, number, body) + .await + .map(|comment| comment.html_url.unwrap_or_default()), } .map_err(|e| e.to_string()) } @@ -766,7 +772,12 @@ fn with_credentials(cmd: &mut tokio::process::Command, ctx: &DeliveryCtx<'_>, au cmd.env("GIT_TERMINAL_PROMPT", "0"); match crate::git_credential::ensure_askpass_script(ctx.data_dir) { Ok(askpass) => { - let username = if auth.username.trim().is_empty() { + // CNB's git server takes the FIXED username `cnb` with the token + // as the password, whatever the account's login is — see + // `git_credential::git_username_for`. + let username = if ctx.provider == ForgeProvider::Cnb { + crate::git_credential::CNB_GIT_USERNAME + } else if auth.username.trim().is_empty() { // No forge checks the username when the password is a token // (Gitea looks the token up and authenticates by it, ignoring // whatever name came with it), but git insists on having one; @@ -775,6 +786,7 @@ fn with_credentials(cmd: &mut tokio::process::Command, ctx: &DeliveryCtx<'_>, au match ctx.provider { ForgeProvider::GitHub | ForgeProvider::Gitea => "x-access-token", ForgeProvider::GitLab => "oauth2", + ForgeProvider::Cnb => crate::git_credential::CNB_GIT_USERNAME, } } else { auth.username.trim() @@ -997,6 +1009,7 @@ impl From for ForgePr { #[cfg(test)] mod tests { use super::*; + use axum::extract::Query; use axum::routing::{get, post}; use axum::Json; @@ -1004,6 +1017,50 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; + /// CNB's git server authenticates by a FIXED username, `cnb`. An account + /// stored with its own login must still send `cnb`, or every push of a + /// delivery is refused. + #[tokio::test] + async fn a_cnb_delivery_sends_the_fixed_git_username() { + let conn = sea_orm::Database::connect("sqlite::memory:") + .await + .expect("db"); + let data_dir = tempfile::tempdir().expect("tempdir"); + let username_sent = |provider: ForgeProvider| { + let ctx = DeliveryCtx { + conn: &conn, + data_dir: data_dir.path(), + provider, + server_host: "cnb.cool", + account_id: "a", + owner_repo: "acme/app", + }; + let auth = ResolvedAuth { + provider, + server_host: "cnb.cool".into(), + api_base: "https://api.cnb.cool".into(), + account_id: "a".into(), + username: "alice".into(), + avatar_url: None, + token: "t0ken".into(), + scopes: vec![], + }; + let mut cmd = tokio::process::Command::new("git"); + with_credentials(&mut cmd, &ctx, &auth); + cmd.as_std() + .get_envs() + .find(|(key, _)| *key == "CODEG_GIT_USERNAME") + .and_then(|(_, value)| value) + .map(|value| value.to_string_lossy().into_owned()) + }; + assert_eq!(username_sent(ForgeProvider::Cnb).as_deref(), Some("cnb")); + // Everyone else keeps the account's own login. + assert_eq!( + username_sent(ForgeProvider::Gitea).as_deref(), + Some("alice") + ); + } + /// The delivery error string is the only thing a human sees when a push /// fails, and `classify_remote_git_error` files everything specific under /// `detail`. `Display` prints `message` alone, so flattening has to reach diff --git a/src-tauri/src/forge/mod.rs b/src-tauri/src/forge/mod.rs index 1ad9bdfe8c4..4f804b84ad3 100644 --- a/src-tauri/src/forge/mod.rs +++ b/src-tauri/src/forge/mod.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod auto; +pub mod cnb; pub mod deliver; pub mod envelope; pub mod gitea; @@ -33,6 +34,12 @@ pub enum ForgeProvider { /// this variant makes. Nothing downstream distinguishes them, and inventing /// a fourth variant would only split one credential store in two. Gitea, + /// CNB (cnb.cool) — Tencent's code hosting platform. Its API is its own: + /// the REST base is a SEPARATE host (`api.cnb.cool`), authentication is a + /// `Bearer` token, and the wire shapes are neither GitHub's nor Gitea's. + /// It does borrow GitHub's `refs/pull//head` namespace, which is what + /// lets a proposed change's head be fetched without adding a remote. + Cnb, } impl ForgeProvider { @@ -41,6 +48,7 @@ impl ForgeProvider { ForgeProvider::GitHub => "github", ForgeProvider::GitLab => "gitlab", ForgeProvider::Gitea => "gitea", + ForgeProvider::Cnb => "cnb", } } @@ -52,6 +60,7 @@ impl ForgeProvider { "github" => Ok(ForgeProvider::GitHub), "gitlab" => Ok(ForgeProvider::GitLab), "gitea" => Ok(ForgeProvider::Gitea), + "cnb" => Ok(ForgeProvider::Cnb), other => Err(ForgeError::Invalid(format!("unknown provider: {other}"))), } } @@ -64,6 +73,7 @@ impl ForgeProvider { ForgeProvider::GitHub => "GitHub", ForgeProvider::GitLab => "GitLab", ForgeProvider::Gitea => "Gitea", + ForgeProvider::Cnb => "CNB", } } @@ -76,6 +86,9 @@ impl ForgeProvider { match self { ForgeProvider::GitHub | ForgeProvider::Gitea => "pull request", ForgeProvider::GitLab => "merge request", + // CNB's own UI and API documentation call it a 合并请求 / merge + // request, even though the REST path is `/pulls`. + ForgeProvider::Cnb => "merge request", } } @@ -93,7 +106,9 @@ impl ForgeProvider { /// check out a Gitea fork's head without adding a remote. pub fn change_head_ref(self, number: i64) -> String { match self { - ForgeProvider::GitHub | ForgeProvider::Gitea => format!("refs/pull/{number}/head"), + ForgeProvider::GitHub | ForgeProvider::Gitea | ForgeProvider::Cnb => { + format!("refs/pull/{number}/head") + } ForgeProvider::GitLab => format!("refs/merge-requests/{number}/head"), } } @@ -140,6 +155,15 @@ impl ForgeProvider { }; format!("{origin}/{owner_repo}/{segment}/{number}") } + ForgeProvider::Cnb => { + // The `/-/` separator, as on GitLab — CNB nests both + // collections under it and uses `pulls` for the change. + let segment = match kind { + ForgeItemKind::Issue => "issues", + ForgeItemKind::Change => "pulls", + }; + format!("{origin}/{owner_repo}/-/{segment}/{number}") + } } } } @@ -209,10 +233,10 @@ pub enum ForgeError { /// The host is not one codeg can read: nothing is configured for it and its /// name claims none of the forges. Distinct from [`ForgeError::NoAccount`] /// because the advice differs — "add a GitHub account for bitbucket.org" - /// is advice that cannot work, while "only GitHub, GitLab and Gitea are - /// supported" is the actual answer (with adding an account still the way + /// is advice that cannot work, while "only GitHub, GitLab, Gitea and CNB + /// are supported" is the actual answer (with adding an account still the way /// in for a self-hosted instance under an unrelated name). - #[error("unsupported forge host {host}: only GitHub, GitLab and Gitea are supported")] + #[error("unsupported forge host {host}: only GitHub, GitLab, Gitea and CNB are supported")] UnsupportedHost { host: String }, /// The host answered in a way only the OTHER forge answers — codeg had it /// classified wrong and now knows better. Recoverable by construction: the @@ -1802,6 +1826,10 @@ pub fn web_origin(auth: &ResolvedAuth) -> String { if base == "https://api.github.com" { return "https://github.com".to_string(); } + // CNB's API lives on its own host, with the web UI a bare label away. + if base == "https://api.cnb.cool" { + return "https://cnb.cool".to_string(); + } match ["/api/v3", "/api/v4", "/api/v1"] .iter() .find_map(|suffix| base.strip_suffix(suffix)) diff --git a/src-tauri/src/git_credential.rs b/src-tauri/src/git_credential.rs index 97903e22212..0a99c541bbe 100644 --- a/src-tauri/src/git_credential.rs +++ b/src-tauri/src/git_credential.rs @@ -357,6 +357,27 @@ pub fn inject_credentials( .env("GIT_TERMINAL_PROMPT", "0"); } +/// The username git should send for a stored account. +/// +/// Usually the account's own login. CNB is the exception: its git server +/// takes a FIXED username, `cnb`, with the access token as the password, and +/// documents that value as the only one to send — the account's login is not +/// a username it authenticates by. +pub fn git_username_for(account: &GitHubAccount) -> &str { + let is_cnb = account + .provider + .as_deref() + .is_some_and(|p| p.trim().eq_ignore_ascii_case("cnb")); + if is_cnb { + CNB_GIT_USERNAME + } else { + &account.username + } +} + +/// See [`git_username_for`]. +pub const CNB_GIT_USERNAME: &str = "cnb"; + /// Get the remote URL for the "origin" remote of a repository. pub async fn get_remote_url(repo_path: &str) -> Option { get_remote_url_by_name(repo_path, "origin").await @@ -569,7 +590,7 @@ pub async fn try_inject_for_repo_remote( "[GIT_CRED] injecting credentials for {} (user: {})", remote_url, account.username ); - inject_credentials(cmd, &account.username, &token, &askpass); + inject_credentials(cmd, git_username_for(account), &token, &askpass); true } @@ -611,7 +632,7 @@ pub async fn try_inject_for_url( } }; - inject_credentials(cmd, &account.username, &token, &askpass); + inject_credentials(cmd, git_username_for(account), &token, &askpass); true } @@ -643,6 +664,24 @@ mod tests { ); } + #[test] + fn a_cnb_account_sends_the_fixed_git_username() { + let account = |provider: Option<&str>| GitHubAccount { + id: "1".into(), + server_url: "https://cnb.cool".into(), + username: "alice".into(), + scopes: vec![], + avatar_url: None, + is_default: true, + created_at: String::new(), + provider: provider.map(str::to_string), + }; + assert_eq!(git_username_for(&account(Some("cnb"))), "cnb"); + // Every other forge authenticates by the account's own login. + assert_eq!(git_username_for(&account(Some("gitea"))), "alice"); + assert_eq!(git_username_for(&account(None)), "alice"); + } + #[test] fn test_find_matching_account() { let accounts = vec![ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18d2fee42fa..208cfed9ace 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2134,6 +2134,7 @@ mod tauri_app { version_control::validate_github_token, version_control::validate_gitlab_token, version_control::validate_gitea_token, + version_control::validate_cnb_token, version_control::update_github_accounts, version_control::save_account_token, version_control::get_account_token, @@ -2331,6 +2332,7 @@ mod tauri_app { work_task_commands::work_task_template_delete, forge_commands::folder_forge_remote, forge_commands::forge_list_issues, + forge_commands::forge_issue_detail, forge_commands::forge_tab_count, forge_commands::forge_list_labels, forge_commands::forge_list_comments, diff --git a/src-tauri/src/web/handlers/forge.rs b/src-tauri/src/web/handlers/forge.rs index 46a968d97d0..dd3813192d7 100644 --- a/src-tauri/src/web/handlers/forge.rs +++ b/src-tauri/src/web/handlers/forge.rs @@ -45,6 +45,15 @@ pub struct ListIssuesParams { pub query: ListFilters, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueDetailParams { + pub folder_id: i32, + pub number: i64, + #[serde(default)] + pub account_id: Option, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct TabCountParams { @@ -172,6 +181,16 @@ pub async fn forge_list_issues( )) } +pub async fn forge_issue_detail( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::forge_issue_detail_core(&state.db, params.folder_id, params.number, params.account_id) + .await?, + )) +} + pub async fn forge_tab_count( Extension(state): Extension>, Json(params): Json, diff --git a/src-tauri/src/web/handlers/version_control.rs b/src-tauri/src/web/handlers/version_control.rs index 0b4d7bffae8..2ac26cb9d7f 100644 --- a/src-tauri/src/web/handlers/version_control.rs +++ b/src-tauri/src/web/handlers/version_control.rs @@ -181,6 +181,15 @@ pub async fn validate_gitea_token( Ok(Json(result)) } +/// CNB's OpenAPI lives on its own host and takes a `Bearer` token; the +/// response shape is CNB's own (`username`/`nickname`/`avatar`). +pub async fn validate_cnb_token( + Json(params): Json, +) -> Result, AppCommandError> { + let result = vc_commands::validate_cnb_token(params.server_url, params.token).await?; + Ok(Json(result)) +} + // --------------------------------------------------------------------------- // Keyring token management // --------------------------------------------------------------------------- diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 5057faa9de8..4240ad86d7a 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -947,6 +947,10 @@ pub fn build_router_with_proxy_auth( "/validate_gitea_token", post(handlers::version_control::validate_gitea_token), ) + .route( + "/validate_cnb_token", + post(handlers::version_control::validate_cnb_token), + ) .route( "/save_account_token", post(handlers::version_control::save_account_token), @@ -1819,6 +1823,10 @@ pub fn build_router_with_proxy_auth( "/forge_list_issues", post(handlers::forge::forge_list_issues), ) + .route( + "/forge_issue_detail", + post(handlers::forge::forge_issue_detail), + ) .route( "/forge_tab_count", post(handlers::forge::forge_tab_count), diff --git a/src/components/forge/forge-issue-detail-sheet.test.tsx b/src/components/forge/forge-issue-detail-sheet.test.tsx index 891ffe4c48b..fac13e956c1 100644 --- a/src/components/forge/forge-issue-detail-sheet.test.tsx +++ b/src/components/forge/forge-issue-detail-sheet.test.tsx @@ -67,6 +67,7 @@ vi.mock("@/components/ai-elements/message", () => ({ ), })) const forgeListComments = vi.hoisted(() => vi.fn()) +const forgeIssueDetail = vi.hoisted(() => vi.fn()) const forgeCreateComment = vi.hoisted(() => vi.fn()) const forgeSetItemState = vi.hoisted(() => vi.fn()) const forgeChangeDetail = vi.hoisted(() => vi.fn()) @@ -76,6 +77,7 @@ const forgeMergeChange = vi.hoisted(() => vi.fn()) const forgeIdentity = vi.hoisted(() => vi.fn()) vi.mock("@/lib/api", () => ({ forgeListComments, + forgeIssueDetail, forgeCreateComment, forgeSetItemState, forgeChangeDetail, @@ -295,6 +297,8 @@ function mount( onRowUpdated?: (updated: ForgeIssueRow) => void onCommentPosted?: (item: { isPr: boolean; number: number }) => void folderId?: number | null + /** CNB mode — see `useIssueBody`. */ + issueBodyOnDemand?: boolean } = {} ) { const onOpenChange = handlers.onOpenChange ?? vi.fn() @@ -307,6 +311,7 @@ function mount( row={item} link={link} folderId={handlers.folderId === undefined ? 7 : handlers.folderId} + issueBodyOnDemand={handlers.issueBodyOnDemand ?? false} onOpenChange={onOpenChange} onStart={onStart} onRowUpdated={onRowUpdated} @@ -332,6 +337,9 @@ beforeEach(() => { // case that is about the header or the footer. The tests that are about the // discussion say what comes back for themselves. forgeListComments.mockReturnValue(new Promise(() => {})) + // The description read a CNB issue's panel makes (see `useIssueBody`). Still + // in flight unless a case says otherwise, for the same reason as the thread. + forgeIssueDetail.mockReturnValue(new Promise(() => {})) // Same rule for the change section: a pull request always asks for both, and // a request that resolved would update state after a test about something // else had finished. The cases that are ABOUT the change say so themselves. @@ -359,6 +367,40 @@ describe("ForgeIssueDetailSheet", () => { expect(screen.queryByText("No description")).not.toBeInTheDocument() }) + /** + * CNB's issue list carries no body (its pull list does), so the panel reads + * the item itself when it opens. Until that answer arrives it must draw + * NOTHING where the description goes: "No description" is a claim, and + * flashing it over an issue that has one is worse than an empty slot. + */ + it("says nothing about the description until a CNB row's read lands", () => { + mount(row({ body: null }), null, { issueBodyOnDemand: true }) + expect(forgeIssueDetail).toHaveBeenCalledWith(7, 42) + expect(screen.queryByTestId("markdown")).not.toBeInTheDocument() + expect(screen.queryByText("No description")).not.toBeInTheDocument() + }) + + it("draws the description a CNB row's read brings back", async () => { + forgeIssueDetail.mockResolvedValue(row({ body: "## From the item" })) + mount(row({ body: null }), null, { issueBodyOnDemand: true }) + await waitFor(() => + expect(screen.getByTestId("markdown")).toHaveTextContent( + "## From the item" + ) + ) + expect(screen.queryByText("No description")).not.toBeInTheDocument() + }) + + /** + * Every other forge ships the body with the row, and must not pay for a + * second read (or have its genuinely empty description withheld). + */ + it("keeps the row's own answer when the list carries a body", () => { + mount(row({ body: "" })) + expect(forgeIssueDetail).not.toHaveBeenCalled() + expect(screen.getByText("No description")).toBeInTheDocument() + }) + /** * The panel's typography goes to the RENDERER, not to a box around it. * diff --git a/src/components/forge/forge-issue-detail-sheet.tsx b/src/components/forge/forge-issue-detail-sheet.tsx index 8a0f31ebf03..7e9c7dac963 100644 --- a/src/components/forge/forge-issue-detail-sheet.tsx +++ b/src/components/forge/forge-issue-detail-sheet.tsx @@ -95,6 +95,7 @@ import { forgeMergeOptions, forgeSetItemState, } from "@/lib/api" +import { useIssueBody } from "@/components/forge/use-issue-body" import { type AppErrorTranslator, toLocalizedErrorMessage, @@ -2208,6 +2209,8 @@ function MergeBox({ */ function Conversation({ row, + description, + descriptionPending = false, folderId, identity, onCommentPosted, @@ -2216,6 +2219,12 @@ function Conversation({ viewportEl, }: { row: ForgeIssueRow + /** The item's description. Passed in rather than read off `row` because a + * CNB issue's list row carries none — it comes from its own read, see + * [`useIssueBody`]. */ + description: string | null + /** That read is still out — see [`useIssueBody`]. */ + descriptionPending?: boolean folderId: number | null /** Who a comment from here would be signed as — see [`useForgeIdentity`]. */ identity: ForgeIdentity | null @@ -2231,7 +2240,7 @@ function Conversation({ viewportEl: HTMLElement | null }) { const t = useTranslations("Forge") - const body = row.body?.trim() + const body = description?.trim() return ( <> {/* The description is the first thing said about the item, so it sits in @@ -2246,7 +2255,10 @@ function Conversation({ label={row.author ?? undefined} />
- {body ? ( + {/* Nothing at all while the description is still being read: "no + description" is a CLAIM, and making it before the answer arrives + would flash that sentence over every issue that has one. */} + {descriptionPending ? null : body ? ( // The forge's own Markdown, through the same renderer the chat // uses — headings, task lists, tables, fenced code and images all // come out as the author wrote them, and link clicks go through @@ -2327,6 +2339,7 @@ export function ForgeIssueDetailSheet({ row, link, folderId, + issueBodyOnDemand = false, onOpenChange, onStart, onRowUpdated, @@ -2342,6 +2355,12 @@ export function ForgeIssueDetailSheet({ * repository from this folder's own remote). `null` while no folder is * resolved, which costs the thread and nothing else. */ folderId: number | null + /** + * This forge's ISSUE list rows carry no description, so the panel reads the + * item itself when it opens (see [`useIssueBody`]). Set only for CNB; the + * others serve the body with the row. + */ + issueBodyOnDemand?: boolean onOpenChange: (open: boolean) => void /** Opens the page's trigger dialog on this item. */ onStart: () => void @@ -2416,6 +2435,9 @@ export function ForgeIssueDetailSheet({ change?.folderId ?? null, change?.number ?? null ) + /** An ISSUE's description when the list did not bring one — CNB only (see + * [`useIssueBody`]); a no-op everywhere else. */ + const issueBody = useIssueBody(folderId, row, issueBodyOnDemand) /** * A change there is still something to land. A merged one is done, and * neither forge will merge a closed change — the box would be an offer to @@ -2706,6 +2728,7 @@ export function ForgeIssueDetailSheet({ > { expect( await screen.findByText( - "The repository panel supports GitHub, GitLab and Gitea only, and gitee.com is not recognized as any of them. If it is a self-hosted GitHub Enterprise, GitLab, Gitea or Forgejo instance, add an account for it under Settings → Version Control." + "The repository panel supports GitHub, GitLab, Gitea and CNB only, and gitee.com is not recognized as any of them. If it is a self-hosted GitHub Enterprise, GitLab, Gitea, Forgejo or CNB instance, add an account for it under Settings → Version Control." ) ).toBeInTheDocument() // No request is worth spending on a host no credential of ours fits — not diff --git a/src/components/forge/forge-page.tsx b/src/components/forge/forge-page.tsx index ee0866f64ca..563c7588a15 100644 --- a/src/components/forge/forge-page.tsx +++ b/src/components/forge/forge-page.tsx @@ -194,7 +194,7 @@ function pillSelectClass(compact: boolean): string { ) } -/** The four orders GitHub and GitLab can honour, in the order they are offered. +/** The four orders the workbench offers, in the order they are offered. * `as const` so the label keys stay literal and next-intl can check them. */ const SORT_OPTIONS = [ { value: "newest", labelKey: "sortNewest" }, @@ -217,6 +217,31 @@ function canSort(provider: ForgeProviderId | undefined): boolean { return provider !== "gitea" } +/** The orders this forge can actually honour. + * + * CNB's `order_by` takes `created_at`, `-created_at` and `-updated_at` — but + * no ASCENDING updated order, so `least_recently_updated` is not offered + * there rather than shown and ignored. Everything else gets all four. */ +function sortOptionsFor(provider: ForgeProviderId | undefined) { + if (provider === "cnb") { + return SORT_OPTIONS.filter((o) => o.value !== "least_recently_updated") + } + return SORT_OPTIONS +} + +/** Whether the CURRENT TAB can be filtered by free text. + * + * CNB's OpenAPI takes a `keyword` on issues only — its merge-request list has + * no such parameter. A search box there would be a control whose input is + * dropped on the floor, so it is not shown. Everything else searches both + * tabs. */ +function supportsSearch( + provider: ForgeProviderId | undefined, + tab: ForgeTab +): boolean { + return !(provider === "cnb" && tab === "prs") +} + /** The raw thrown value, boxed. Boxed for two reasons: `setState` would CALL a * bare thrown function as an updater, and a raw `null` is indistinguishable * from "no error". Localization happens at render, not here, so the translator @@ -1051,6 +1076,25 @@ export function ForgePage({ embed }: { embed?: ForgePageEmbed } = {}) { return () => clearTimeout(handle) }, [searchInput, search]) + // A tab that cannot be searched must not keep a query it cannot apply: the + // box is gone, so text left behind would either do nothing (a list that + // ignores what the control says) or come back as a filter the moment the + // other tab is reopened, with no visible reason for it. + useEffect(() => { + if (supportsSearch(remote?.provider, tab)) return + setSearchInput("") + setSearch("") + }, [remote?.provider, tab]) + + // Same rule for the order: a forge that cannot honour the current one (a + // folder switch from GitLab to CNB, say) falls back to the default rather + // than leaving the select showing a choice its list no longer contains. + useEffect(() => { + if (sortOptionsFor(remote?.provider).some((o) => o.value === sort)) return + setPage(1) + setSort("newest") + }, [remote?.provider, sort]) + // A different repository has a different label vocabulary, so a selection // made against the old one would filter by labels that may not exist here. // Derived during render rather than in an effect: this has to catch the @@ -1465,7 +1509,12 @@ export function ForgePage({ embed }: { embed?: ForgePageEmbed } = {}) { } }, [error, tRoot]) - const changeNounKey = remote?.provider === "gitlab" ? "tabMrs" : "tabPrs" + // GitLab and CNB call the same thing a merge request; GitHub and Gitea a + // pull request. + const changeNounKey = + remote?.provider === "gitlab" || remote?.provider === "cnb" + ? "tabMrs" + : "tabPrs" return (
@@ -1580,41 +1629,43 @@ export function ForgePage({ embed }: { embed?: ForgePageEmbed } = {}) { pills after it narrow whatever it caught. A whole row to itself on a phone (`w-full` forces the wrap) — sharing one would leave too little of it to read back what you typed. */} - - - - - setSearchInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape" && searchInput !== "") { - e.preventDefault() - setSearchInput("") - } - }} - placeholder={t("searchPlaceholder")} - aria-label={t("searchPlaceholder")} - className={embedded ? "h-7 text-xs" : "h-8 text-[0.8125rem]"} - /> - {searchInput !== "" ? ( - - setSearchInput("")} - > - - + {supportsSearch(remote?.provider, tab) ? ( + + + - ) : null} - + setSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape" && searchInput !== "") { + e.preventDefault() + setSearchInput("") + } + }} + placeholder={t("searchPlaceholder")} + aria-label={t("searchPlaceholder")} + className={embedded ? "h-7 text-xs" : "h-8 text-[0.8125rem]"} + /> + {searchInput !== "" ? ( + + setSearchInput("")} + > + + + + ) : null} + + ) : null} {/* Was a button whose LABEL changed on click, which reads equally as "you are here" and "go here". Named options remove the guess — @@ -1695,7 +1746,7 @@ export function ForgePage({ embed }: { embed?: ForgePageEmbed } = {}) { - {SORT_OPTIONS.map((option) => ( + {sortOptionsFor(remote?.provider).map((option) => ( {t(option.labelKey)} @@ -1817,6 +1868,9 @@ export function ForgePage({ embed }: { embed?: ForgePageEmbed } = {}) { // list was fetched with, so a folder switch (which closes the panel — // see the reset effect above) cannot leave the two disagreeing. folderId={effectiveFolderId} + // CNB's issue list carries no description; the panel reads the item + // itself when it opens. Every other forge ships the body with the row. + issueBodyOnDemand={remote?.provider === "cnb"} onOpenChange={(open) => { if (!open) setDetailRow(null) }} diff --git a/src/components/forge/forge-start-dialog.test.tsx b/src/components/forge/forge-start-dialog.test.tsx index 1254707605f..3f310016096 100644 --- a/src/components/forge/forge-start-dialog.test.tsx +++ b/src/components/forge/forge-start-dialog.test.tsx @@ -25,9 +25,11 @@ import type { import { ForgeStartDialog } from "./forge-start-dialog" const workTaskCreateFromForge = vi.fn() +const forgeIssueDetail = vi.fn() vi.mock("@/lib/api", () => ({ workTaskCreateFromForge: (...args: unknown[]) => workTaskCreateFromForge(...args), + forgeIssueDetail: (...args: unknown[]) => forgeIssueDetail(...args), })) const setRoute = vi.fn() @@ -47,6 +49,13 @@ const GITHUB: ForgeRemote = { provider: "github", supported: true, } +const CNB: ForgeRemote = { + server_host: "cnb.cool", + owner_repo: "acme/app", + remote_url: "https://cnb.cool/acme/app.git", + provider: "cnb", + supported: true, +} const GITLAB: ForgeRemote = { server_host: "gitlab.com", owner_repo: "group/sub/app", @@ -160,6 +169,39 @@ describe("ForgeStartDialog", () => { }) }) + /** + * CNB's issue list carries no description. The preview reads it, because + * what the preview shows is what the envelope wraps — and the read body is + * what goes out (the server fills one still missing at Create). + */ + it("previews and sends the description a CNB issue's read brings back", async () => { + const user = userEvent.setup() + forgeIssueDetail.mockResolvedValue(row({ body: "read from the item" })) + workTaskCreateFromForge.mockResolvedValueOnce({ + outcome: "created", + task: task(), + }) + mount(row({ body: null }), CNB) + + expect(forgeIssueDetail).toHaveBeenCalledWith(7, 42) + await user.click(screen.getByText(enMessages.Forge.previewToggle)) + expect(await screen.findByText("read from the item")).toBeInTheDocument() + expect( + screen.queryByText(enMessages.Forge.previewEmpty) + ).not.toBeInTheDocument() + + await user.click(screen.getByRole("button", { name: "Create task" })) + await waitFor(() => expect(workTaskCreateFromForge).toHaveBeenCalled()) + expect(workTaskCreateFromForge.mock.calls[0][0].snapshot.body).toBe( + "read from the item" + ) + }) + + it("reads nothing for a forge whose rows carry the body", () => { + mount(row({ body: null }), GITLAB) + expect(forgeIssueDetail).not.toHaveBeenCalled() + }) + it("carries the write-back choice, off when the switch is cleared", async () => { const user = userEvent.setup() workTaskCreateFromForge.mockResolvedValueOnce({ diff --git a/src/components/forge/forge-start-dialog.tsx b/src/components/forge/forge-start-dialog.tsx index 7870b1271fb..c6cb23bc67c 100644 --- a/src/components/forge/forge-start-dialog.tsx +++ b/src/components/forge/forge-start-dialog.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/collapsible" import { cn } from "@/lib/utils" import { workTaskCreateFromForge } from "@/lib/api" +import { useIssueBody } from "@/components/forge/use-issue-body" import { toLocalizedErrorMessage, type AppErrorTranslator, @@ -37,11 +38,13 @@ import type { WorkTask, } from "@/lib/types" -/** GitLab calls it a merge request, and a dialog that promises a pull request - * and then opens a merge request reads like the wrong tool answered. */ +/** GitLab and CNB call it a merge request, and a dialog that promises a pull + * request and then opens a merge request reads like the wrong tool answered. */ function descriptionKey(isPr: boolean, provider: ForgeProviderId) { if (!isPr) return "dialogDescription" - return provider === "gitlab" ? "dialogDescriptionMr" : "dialogDescriptionPr" + return provider === "gitlab" || provider === "cnb" + ? "dialogDescriptionMr" + : "dialogDescriptionPr" } /** The kind's scenario menu, default first — the server enforces the same @@ -123,6 +126,10 @@ export function ForgeStartDialog({ ) ) const [instruction, setInstruction] = useState("") + // CNB's issue list carries no description, so the preview reads it — what + // the preview shows is what the envelope wraps. The server fills a body that + // is still missing at Create, so submitting before this lands loses nothing. + const issueBody = useIssueBody(folderId, row, remote.provider === "cnb") // Asked here, once per work item, rather than kept as a project-wide switch: // this is the one thing the task will do in a thread other people are // reading, so the decision belongs in front of the item it publishes on. @@ -150,7 +157,7 @@ export function ForgeStartDialog({ }, snapshot: { title: row.title, - body: row.body, + body: issueBody.body, // Names only: the snapshot is TEXT handed to an agent, and a colour // means nothing there. labels: row.labels.map((l) => l.name), @@ -288,7 +295,9 @@ export function ForgeStartDialog({ {/* Read-only: this is the DATA the server-side envelope wraps. */}
- {row.body?.trim() || t("previewEmpty")} + {issueBody.pending + ? null + : issueBody.body?.trim() || t("previewEmpty")}
diff --git a/src/components/forge/use-issue-body.ts b/src/components/forge/use-issue-body.ts new file mode 100644 index 00000000000..1faf761f3b9 --- /dev/null +++ b/src/components/forge/use-issue-body.ts @@ -0,0 +1,66 @@ +"use client" + +import { useEffect, useState } from "react" +import { forgeIssueDetail } from "@/lib/api" +import type { ForgeIssueRow } from "@/lib/types" + +/** + * The description of an issue whose LIST row carries none. + * + * CNB's issue list omits `body` entirely (its pull list carries one), so the + * first paint has nothing to draw where the description goes. One read, paid + * when the panel (or the trigger dialog) opens — the same trade the comment + * thread makes, and for the same reason: a list page holds thirty items whose + * reader opens at most one. Scoped to that forge by the caller; every other + * one ships the body on the row and never asks. + * + * `pending` is its own state rather than "body is null", because the two + * answer different questions — "the forge has not said yet" and "the forge + * says there is nothing" — and only the second may draw the empty-state line. + */ +export function useIssueBody( + folderId: number | null, + row: ForgeIssueRow | null, + enabled: boolean +): { body: string | null; pending: boolean } { + const [read, setRead] = useState<{ key: string; body: string | null } | null>( + null + ) + // The folder is part of the key: issue #42 of one repository is not #42 of + // another, and a read held for the first must not answer for the second. + const key = + row != null + ? `${folderId ?? "none"}:${row.is_pr ? "pr" : "issue"}-${row.number}` + : null + const onRow = row?.body ?? null + const wanted = enabled && row != null && !row.is_pr && onRow == null + const number = row?.number ?? null + + useEffect(() => { + if (!wanted || key == null || number == null || folderId == null) return + let live = true + forgeIssueDetail(folderId, number) + .then((full) => { + if (live) setRead({ key, body: full.body ?? null }) + }) + .catch(() => { + // The caller keeps drawing — the thread reports its own failures. + // Falling back to "no description" is the honest cost of one failed + // read: the alternative is an empty slot that never fills. + if (live) setRead({ key, body: null }) + }) + return () => { + live = false + } + // `onRow` is a dependency so a row that ARRIVES with a body — a list + // refresh, or a write the page adopted — retires the read instead of + // leaving a second copy to disagree with it. + }, [wanted, folderId, key, number, onRow]) + + if (!wanted) return { body: onRow, pending: false } + // No folder, no read — and so nothing to wait for. Saying "pending" here + // would leave an empty slot that never fills. + if (folderId == null) return { body: null, pending: false } + if (read?.key !== key) return { body: null, pending: true } + return { body: read.body, pending: false } +} diff --git a/src/components/layout/aux-panel.tsx b/src/components/layout/aux-panel.tsx index c517ce93dda..12c585e3ab2 100644 --- a/src/components/layout/aux-panel.tsx +++ b/src/components/layout/aux-panel.tsx @@ -182,7 +182,11 @@ export function AuxPanel() { case "issues": return tForge("tabIssues") case "pull_requests": - return tForge(forgeProvider === "gitlab" ? "tabMrs" : "tabPrs") + return tForge( + forgeProvider === "gitlab" || forgeProvider === "cnb" + ? "tabMrs" + : "tabPrs" + ) default: return t(FOLDER_TAB_LABEL_KEY[tab]) } diff --git a/src/components/settings/add-forge-account-dialog.tsx b/src/components/settings/add-forge-account-dialog.tsx index 7c81bcdbf6d..7c0cb18af4c 100644 --- a/src/components/settings/add-forge-account-dialog.tsx +++ b/src/components/settings/add-forge-account-dialog.tsx @@ -19,6 +19,7 @@ import { validateGitHubToken, validateGitLabToken, validateGiteaToken, + validateCnbToken, saveAccountToken, } from "@/lib/api" import type { ForgeProviderId, GitHubAccount } from "@/lib/types" @@ -57,6 +58,10 @@ function tokenPageUrl(provider: ForgeProviderId, serverUrl: string): string { if (provider === "gitea") { return `${base}/user/settings/applications` } + if (provider === "cnb") { + // CNB: personal settings → access tokens. + return `${base}/profile/token` + } const params = new URLSearchParams({ description: "codeg", scopes: "repo,read:org,workflow,gist,read:user,user:email", @@ -69,6 +74,7 @@ function defaultServerUrl(provider: ForgeProviderId): string { // gitea.com is the public instance, but a Gitea is far more often somebody's // own — the field stays editable and the placeholder says so. if (provider === "gitea") return "https://gitea.com" + if (provider === "cnb") return "https://cnb.cool" return "https://github.com" } @@ -78,6 +84,7 @@ const VALIDATORS: Record = { github: validateGitHubToken, gitlab: validateGitLabToken, gitea: validateGiteaToken, + cnb: validateCnbToken, } /** The dialog's own copy, per forge. Same keys, same order, one lookup — which @@ -89,6 +96,7 @@ const COPY = { github: { description: "githubDescription", tokenHint: "tokenHint" }, gitlab: { description: "gitlabDescription", tokenHint: "gitlabTokenHint" }, gitea: { description: "giteaDescription", tokenHint: "giteaTokenHint" }, + cnb: { description: "cnbDescription", tokenHint: "cnbTokenHint" }, } as const satisfies Record< ForgeProviderId, { description: string; tokenHint: string } diff --git a/src/components/settings/version-control-settings.test.tsx b/src/components/settings/version-control-settings.test.tsx index 6ef0c9c0431..8b88f72fbaf 100644 --- a/src/components/settings/version-control-settings.test.tsx +++ b/src/components/settings/version-control-settings.test.tsx @@ -12,6 +12,7 @@ vi.mock("@/lib/api", () => ({ validateGitHubToken: vi.fn(), validateGitLabToken: vi.fn(), validateGiteaToken: vi.fn(), + validateCnbToken: vi.fn(), getAccountToken: vi.fn(), deleteAccountToken: vi.fn(), saveAccountToken: vi.fn(), diff --git a/src/components/settings/version-control-settings.tsx b/src/components/settings/version-control-settings.tsx index 61ea0aa25b6..dcc223cae69 100644 --- a/src/components/settings/version-control-settings.tsx +++ b/src/components/settings/version-control-settings.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { CheckCircle2, + Cloud, GitBranch, GitFork, Github, @@ -39,6 +40,7 @@ import { validateGitHubToken, validateGitLabToken, validateGiteaToken, + validateCnbToken, getAccountToken, deleteAccountToken, } from "@/lib/api" @@ -79,12 +81,18 @@ function isGiteaAccount(account: GitHubAccount): boolean { return account.provider === "gitea" } +/** CNB, like Gitea, only ever arrives declared. */ +function isCnbAccount(account: GitHubAccount): boolean { + return account.provider === "cnb" +} + /** Which "who am I" endpoint tests an account's stored token, or `null` for a * plain git credential — those validate against nothing, so the most that can * be said is that the keyring still holds the secret. */ function validatorFor( account: GitHubAccount ): typeof validateGitHubToken | null { + if (isCnbAccount(account)) return validateCnbToken if (isGiteaAccount(account)) return validateGiteaToken if (isGitLabAccount(account)) return validateGitLabToken if (isGitHubAccount(account)) return validateGitHubToken @@ -95,6 +103,7 @@ function validatorFor( * declaration decides; GitHub is the fallback because an undeclared account * only ever reaches the rotate action from the GitHub section. */ function providerOf(account: GitHubAccount): ForgeProviderId { + if (isCnbAccount(account)) return "cnb" if (isGiteaAccount(account)) return "gitea" if (isGitLabAccount(account)) return "gitlab" return "github" @@ -229,6 +238,7 @@ export function VersionControlSettings() { const [addGitHubOpen, setAddGitHubOpen] = useState(false) const [addGitLabOpen, setAddGitLabOpen] = useState(false) const [addGiteaOpen, setAddGiteaOpen] = useState(false) + const [addCnbOpen, setAddCnbOpen] = useState(false) const [rotateTarget, setRotateTarget] = useState(null) const [addGitOpen, setAddGitOpen] = useState(false) const [testingAccountId, setTestingAccountId] = useState(null) @@ -247,10 +257,18 @@ export function VersionControlSettings() { () => accounts.accounts.filter(isGiteaAccount), [accounts] ) + const cnbAccounts = useMemo( + () => accounts.accounts.filter(isCnbAccount), + [accounts] + ) const gitAccounts = useMemo( () => accounts.accounts.filter( - (a) => !isGitHubAccount(a) && !isGitLabAccount(a) && !isGiteaAccount(a) + (a) => + !isGitHubAccount(a) && + !isGitLabAccount(a) && + !isGiteaAccount(a) && + !isCnbAccount(a) ), [accounts] ) @@ -638,6 +656,44 @@ export function VersionControlSettings() {
+ {/* ---- CNB Accounts ---- */} +
+
+ +

{t("cnbTitle")}

+
+

+ {t("cnbDescription")} +

+ + {cnbAccounts.length === 0 ? ( +
+ {t("cnbNoAccounts")} +
+ ) : ( +
+ {cnbAccounts.map((account) => ( + + ))} +
+ )} + +
+ +
+
+ {/* ---- Git Accounts (non-forge) ---- */}
@@ -700,6 +756,13 @@ export function VersionControlSettings() { onAccountAdded={handleAccountAdded} isFirstAccount={accounts.accounts.length === 0} /> + {rotateTarget && ( { + return getTransport().call("validate_cnb_token", { serverUrl, token }) +} + export async function updateGitHubAccounts( settings: GitHubAccountsSettings ): Promise { @@ -6245,6 +6253,24 @@ export async function forgeListIssues( }) } +/** One issue, read in full — its description included. + * + * CNB's issue LIST omits the body (its pull list carries one), so the detail + * panel asks for the item itself the moment it opens — one request, paid only + * for the item somebody is reading, the same trade the comment thread makes. + * No other forge takes this path: their list rows already carry the body. */ +export async function forgeIssueDetail( + folderId: number, + number: number, + accountId?: string | null +): Promise { + return getTransport().call("forge_issue_detail", { + folderId, + number, + accountId: accountId ?? null, + }) +} + /** Everything a COUNT may be narrowed by. No page and no order: neither can * change the number, which is what lets the switcher survive a page turn * without spending a request. */ diff --git a/src/lib/types.ts b/src/lib/types.ts index 0ec29db6a88..429726715c8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2421,10 +2421,10 @@ export interface ForgeRemote { * accounts and the hostname, never chosen here. */ provider: ForgeProviderId /** Whether `provider` is KNOWN rather than assumed — an account configured - * for the host, or a hostname naming one of the two forges. `false` is a - * remote that parsed perfectly well but lives somewhere codeg cannot read - * (Bitbucket, Gitee, a Gitea): the panel says only GitHub and GitLab are - * supported rather than spending a call that fails as a raw API error. */ + * for the host, or a hostname naming one of the supported forges. `false` is + * a remote that parsed perfectly well but lives somewhere codeg cannot read + * (Bitbucket, Gitee): the panel names the forges it does support rather than + * spending a call that fails as a raw API error. */ supported: boolean } @@ -4596,8 +4596,9 @@ export interface GitHubAccount { /** Mirrors `forge::ForgeProvider`. `"gitea"` covers Forgejo too — it is a * Gitea fork serving the same `/api/v1`, and one wire value keeps one - * instance's accounts and provenance keys from splitting in two. */ -export type ForgeProviderId = "github" | "gitlab" | "gitea" + * instance's accounts and provenance keys from splitting in two. `"cnb"` is + * Tencent's CNB (cnb.cool), whose OpenAPI lives on its own host. */ +export type ForgeProviderId = "github" | "gitlab" | "gitea" | "cnb" export interface GitHubAccountsSettings { accounts: GitHubAccount[]