From 026e750402b3097db9a75e287040dcccd1411ce2 Mon Sep 17 00:00:00 2001 From: abigmiu Date: Fri, 30 Oct 2026 14:30:11 +0800 Subject: [PATCH 1/3] feat(forge): support CNB (cnb.cool) as a forge provider CNB is Tencent's code hosting platform. Its API is not a GitHub or Gitea dialect: the REST base is a separate host (api.cnb.cool), auth is a Bearer token, ids are strings on the wire, and issues/merge requests are separate collections. A GitHub fallback would answer every request with an HTML page and a 200, so it gets its own provider variant and client. Backend: - forge/cnb.rs: list/detail/comments/labels/state/create for issues and merge requests, changed-file list, merge, and the delivery path (find/create/get pull), plus the scan used by auto-processing - ForgeProvider::Cnb wired through mod.rs, auth.rs (host detection, api_base_for, web_origin), auto, deliver and every forge_* command - validate_cnb_token: GET /user against api.cnb.cool, with 403 read as a missing scope rather than a bad token - forge_issue_detail: CNB's issue LIST omits the body, so the panel reads the item itself when it opens (CNB only; other forges ship it on the row) Frontend: - CNB accounts section, add-account dialog, token validation - merge-request wording for CNB everywhere GitLab already had it - forge page: hide the search box on CNB's PR tab (no keyword parameter), drop least-recently-updated from its sort options (no ascending updated order), and reset a filter that the current forge cannot apply - useIssueBody: fetch an issue's description on demand, drawing nothing until the answer lands rather than flashing "No description" - i18n for all 10 locales --- src-tauri/src/commands/forge.rs | 70 + src-tauri/src/commands/version_control.rs | 121 ++ src-tauri/src/forge/auth.rs | 20 +- src-tauri/src/forge/auto/mod.rs | 5 +- src-tauri/src/forge/cnb.rs | 1357 +++++++++++++++++ src-tauri/src/forge/deliver.rs | 15 +- src-tauri/src/forge/mod.rs | 36 +- src-tauri/src/lib.rs | 2 + src-tauri/src/web/handlers/forge.rs | 19 + src-tauri/src/web/handlers/version_control.rs | 9 + src-tauri/src/web/router.rs | 8 + .../forge/forge-issue-detail-sheet.test.tsx | 42 + .../forge/forge-issue-detail-sheet.tsx | 82 +- src/components/forge/forge-page.test.tsx | 2 +- src/components/forge/forge-page.tsx | 128 +- src/components/forge/forge-start-dialog.tsx | 8 +- src/components/layout/aux-panel.tsx | 6 +- .../settings/add-forge-account-dialog.tsx | 8 + .../version-control-settings.test.tsx | 1 + .../settings/version-control-settings.tsx | 65 +- src/components/tasks/task-acceptance.ts | 2 +- src/i18n/messages/ar.json | 6 +- src/i18n/messages/de.json | 6 +- src/i18n/messages/en.json | 6 +- src/i18n/messages/es.json | 6 +- src/i18n/messages/fr.json | 6 +- src/i18n/messages/ja.json | 6 +- src/i18n/messages/ko.json | 6 +- src/i18n/messages/pt.json | 6 +- src/i18n/messages/zh-CN.json | 6 +- src/i18n/messages/zh-TW.json | 6 +- src/lib/api.ts | 26 + src/lib/types.ts | 13 +- 33 files changed, 2034 insertions(+), 71 deletions(-) create mode 100644 src-tauri/src/forge/cnb.rs diff --git a/src-tauri/src/commands/forge.rs b/src-tauri/src/commands/forge.rs index 05cceca6d12..f2df3e48e6a 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. @@ -1297,6 +1356,17 @@ pub async fn forge_list_issues( forge_list_issues_core(&db, folder_id, query).await } +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn forge_issue_detail( + db: tauri::State<'_, AppDatabase>, + folder_id: i32, + number: i64, + account_id: Option, +) -> 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..4810b86e263 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 } @@ -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..69473edc812 --- /dev/null +++ b/src-tauri/src/forge/cnb.rs @@ -0,0 +1,1357 @@ +//! 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)) +} + +/// 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}"))) +} + +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, + updated_at: self.last_acted_at.or(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)); + ForgeComment { + id: self.id, + author, + author_avatar, + body: truncate_chars(self.body.as_deref().unwrap_or_default(), BODY_CAP), + updated_at: ForgeComment::edited_at(self.created_at.as_deref(), self.updated_at), + created_at: self.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, + }, + ) + } + + #[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..f66da993394 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()) } @@ -775,6 +781,9 @@ fn with_credentials(cmd: &mut tokio::process::Command, ctx: &DeliveryCtx<'_>, au match ctx.provider { ForgeProvider::GitHub | ForgeProvider::Gitea => "x-access-token", ForgeProvider::GitLab => "oauth2", + // CNB's git server accepts the access token as the + // password; its own credential helper sends the account name. + ForgeProvider::Cnb => "cnb", } } else { auth.username.trim() 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/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..8a3c7654510 100644 --- a/src/components/forge/forge-issue-detail-sheet.tsx +++ b/src/components/forge/forge-issue-detail-sheet.tsx @@ -90,6 +90,7 @@ import { forgeChangeFiles, forgeCreateComment, forgeIdentity, + forgeIssueDetail, forgeListComments, forgeMergeChange, forgeMergeOptions, @@ -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 @@ -2323,10 +2335,64 @@ function Conversation({ * rather than a local guess — see `forge_create_comment_core` and * `forge_set_item_state_core`. */ +/** + * 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 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. + */ +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 + ) + const key = row != null ? `${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 panel keeps drawing — the thread below 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 } + if (read?.key !== key) return { body: null, pending: true } + return { body: read.body, pending: false } +} + export function ForgeIssueDetailSheet({ row, link, folderId, + issueBodyOnDemand = false, onOpenChange, onStart, onRowUpdated, @@ -2342,6 +2408,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 +2488,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 +2781,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.tsx b/src/components/forge/forge-start-dialog.tsx index 7870b1271fb..03ec84e8129 100644 --- a/src/components/forge/forge-start-dialog.tsx +++ b/src/components/forge/forge-start-dialog.tsx @@ -37,11 +37,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 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[] From 109bfef346ead7fae9956632ceb7a56b128971ba Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 3 Nov 2026 02:01:56 +0800 Subject: [PATCH 2/3] fix(forge): give CNB issue tasks their description CNB's issue list carries no body, and both places that mint a task (the trigger dialog and an auto-processing scan) build the prompt's snapshot from a list row, so every CNB issue task handed its agent a title and an empty fence. Task creation now reads the issue body when the snapshot has none, and the trigger dialog's preview reads it too, so what it shows is what the envelope wraps. useIssueBody moves to its own module for the two callers and no longer waits forever without a folder. Also: an empty last_acted_at/created_at (CNB's spelling of no time) no longer shadows updated_at or reaches the panel, and cnb.cool skips the Gitea/GitLab probe its web host answers with HTML. --- src-tauri/src/commands/forge.rs | 10 +- src-tauri/src/forge/auth.rs | 2 +- src-tauri/src/forge/cnb.rs | 151 +++++++++++++++++- .../forge/forge-issue-detail-sheet.tsx | 55 +------ .../forge/forge-start-dialog.test.tsx | 42 +++++ src/components/forge/forge-start-dialog.tsx | 11 +- src/components/forge/use-issue-body.ts | 61 +++++++ 7 files changed, 271 insertions(+), 61 deletions(-) create mode 100644 src/components/forge/use-issue-body.ts diff --git a/src-tauri/src/commands/forge.rs b/src-tauri/src/commands/forge.rs index f2df3e48e6a..70740e03bbb 100644 --- a/src-tauri/src/commands/forge.rs +++ b/src-tauri/src/commands/forge.rs @@ -1162,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(), diff --git a/src-tauri/src/forge/auth.rs b/src-tauri/src/forge/auth.rs index 4810b86e263..58f765f7a1b 100644 --- a/src-tauri/src/forge/auth.rs +++ b/src-tauri/src/forge/auth.rs @@ -569,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()?; diff --git a/src-tauri/src/forge/cnb.rs b/src-tauri/src/forge/cnb.rs index 69473edc812..706707a6645 100644 --- a/src-tauri/src/forge/cnb.rs +++ b/src-tauri/src/forge/cnb.rs @@ -142,6 +142,32 @@ pub async fn issue_detail( 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; @@ -659,6 +685,11 @@ fn repo_ref(owner_repo: &str) -> Result { .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!( @@ -899,7 +930,10 @@ impl RawItem { .collect(), author, author_avatar, - updated_at: self.last_acted_at.or(self.updated_at), + // 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, @@ -1037,13 +1071,14 @@ impl RawComment { ) }) .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(self.created_at.as_deref(), self.updated_at), - created_at: self.created_at, + 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, @@ -1339,6 +1374,116 @@ mod tests { ) } + 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| { diff --git a/src/components/forge/forge-issue-detail-sheet.tsx b/src/components/forge/forge-issue-detail-sheet.tsx index 8a3c7654510..7e9c7dac963 100644 --- a/src/components/forge/forge-issue-detail-sheet.tsx +++ b/src/components/forge/forge-issue-detail-sheet.tsx @@ -90,12 +90,12 @@ import { forgeChangeFiles, forgeCreateComment, forgeIdentity, - forgeIssueDetail, forgeListComments, forgeMergeChange, forgeMergeOptions, forgeSetItemState, } from "@/lib/api" +import { useIssueBody } from "@/components/forge/use-issue-body" import { type AppErrorTranslator, toLocalizedErrorMessage, @@ -2335,59 +2335,6 @@ function Conversation({ * rather than a local guess — see `forge_create_comment_core` and * `forge_set_item_state_core`. */ -/** - * 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 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. - */ -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 - ) - const key = row != null ? `${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 panel keeps drawing — the thread below 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 } - if (read?.key !== key) return { body: null, pending: true } - return { body: read.body, pending: false } -} - export function ForgeIssueDetailSheet({ row, link, 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 03ec84e8129..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, @@ -125,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. @@ -152,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), @@ -290,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..a529cc3f609 --- /dev/null +++ b/src/components/forge/use-issue-body.ts @@ -0,0 +1,61 @@ +"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 + ) + const key = row != null ? `${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 } +} From c6b1e61640f7516fae70d25aa99a4166a68bdb66 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 3 Nov 2026 02:15:27 +0800 Subject: [PATCH 3/3] fix(forge): sign in to CNB's git server as cnb, ask for a pushable token CNB's git server documents a FIXED username, cnb, with the access token as the password. Delivery only used that placeholder when the stored username was empty, and the app's own push/pull/clone credential path always sent the account's login, so every authenticated git operation against a CNB account sent the wrong name. Both now send cnb for a CNB account. The token hint asked for repo-code:r, which cannot push a delivery; it now asks for repo-code:rw in every locale. useIssueBody's cache key also carries the folder, so one repository's #42 never answers for another's. --- src-tauri/src/forge/deliver.rs | 56 ++++++++++++++++++++++++-- src-tauri/src/git_credential.rs | 43 +++++++++++++++++++- src/components/forge/use-issue-body.ts | 7 +++- src/i18n/messages/ar.json | 2 +- src/i18n/messages/de.json | 2 +- src/i18n/messages/en.json | 2 +- src/i18n/messages/es.json | 2 +- src/i18n/messages/fr.json | 2 +- src/i18n/messages/ja.json | 2 +- src/i18n/messages/ko.json | 2 +- src/i18n/messages/pt.json | 2 +- src/i18n/messages/zh-CN.json | 2 +- src/i18n/messages/zh-TW.json | 2 +- 13 files changed, 109 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/forge/deliver.rs b/src-tauri/src/forge/deliver.rs index f66da993394..32e9d31f526 100644 --- a/src-tauri/src/forge/deliver.rs +++ b/src-tauri/src/forge/deliver.rs @@ -772,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; @@ -781,9 +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", - // CNB's git server accepts the access token as the - // password; its own credential helper sends the account name. - ForgeProvider::Cnb => "cnb", + ForgeProvider::Cnb => crate::git_credential::CNB_GIT_USERNAME, } } else { auth.username.trim() @@ -1006,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; @@ -1013,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/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/components/forge/use-issue-body.ts b/src/components/forge/use-issue-body.ts index a529cc3f609..1faf761f3b9 100644 --- a/src/components/forge/use-issue-body.ts +++ b/src/components/forge/use-issue-body.ts @@ -26,7 +26,12 @@ export function useIssueBody( const [read, setRead] = useState<{ key: string; body: string | null } | null>( null ) - const key = row != null ? `${row.is_pr ? "pr" : "issue"}-${row.number}` : 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 diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 032a1dbc0e1..271352761e5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "لا يوجد حساب Gitea بعد", "cnbTitle": "حسابات CNB", "cnbDescription": "أدر حسابات CNB (cnb.cool) للمصادقة. تُخزَّن الرموز محليًا.", - "cnbTokenHint": "أنشئ رمز وصول من CNB ← الإعدادات الشخصية ← رموز الوصول مع النطاقات account-profile:r و repo-issue:rw و repo-pr:rw و repo-notes:rw و repo-code:r. بدون account-profile:r لن يخبر CNB عن المستخدم الذي يخصه الرمز، ولن يمكن إضافة الحساب.", + "cnbTokenHint": "أنشئ رمز وصول من CNB ← الإعدادات الشخصية ← رموز الوصول مع النطاقات account-profile:r و repo-issue:rw و repo-pr:rw و repo-notes:rw و repo-code:rw. بدون account-profile:r لن يخبر CNB عن المستخدم الذي يخصه الرمز، ولن يمكن إضافة الحساب.", "cnbNoAccounts": "لا يوجد حساب CNB بعد", "noAccounts": "لا توجد حسابات GitHub مكوّنة.", "addAccount": "إضافة حساب", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 37ca401957c..80117c8ab21 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "Noch kein Gitea-Konto", "cnbTitle": "CNB-Konten", "cnbDescription": "Verwalte CNB-Konten (cnb.cool) für die Authentifizierung. Tokens werden lokal gespeichert.", - "cnbTokenHint": "Erstelle unter CNB → Persönliche Einstellungen → Zugriffstokens ein Zugriffstoken mit den Scopes account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw und repo-code:r. Ohne account-profile:r nennt CNB nicht, zu welchem Benutzer das Token gehört, und das Konto lässt sich nicht hinzufügen.", + "cnbTokenHint": "Erstelle unter CNB → Persönliche Einstellungen → Zugriffstokens ein Zugriffstoken mit den Scopes account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw und repo-code:rw. Ohne account-profile:r nennt CNB nicht, zu welchem Benutzer das Token gehört, und das Konto lässt sich nicht hinzufügen.", "cnbNoAccounts": "Noch kein CNB-Konto", "noAccounts": "Keine GitHub-Konten konfiguriert.", "addAccount": "Konto hinzufügen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d71546f1ae4..36c5644c2b1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "No Gitea account yet", "cnbTitle": "CNB accounts", "cnbDescription": "Manage CNB (cnb.cool) accounts for authentication. Tokens are stored locally.", - "cnbTokenHint": "Create an access token under CNB → Personal settings → Access tokens with the account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw and repo-code:r scopes. Without account-profile:r, CNB will not say which user the token belongs to, and the account cannot be added.", + "cnbTokenHint": "Create an access token under CNB → Personal settings → Access tokens with the account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw and repo-code:rw scopes. Without account-profile:r, CNB will not say which user the token belongs to, and the account cannot be added.", "cnbNoAccounts": "No CNB account yet", "noAccounts": "No GitHub accounts configured.", "addAccount": "Add Account", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1ebfcfb01fe..0758b01bbe1 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "Aún no hay ninguna cuenta de Gitea", "cnbTitle": "Cuentas de CNB", "cnbDescription": "Gestiona cuentas de CNB (cnb.cool) para la autenticación. Los tokens se guardan localmente.", - "cnbTokenHint": "Crea un token de acceso en CNB → Configuración personal → Tokens de acceso con los permisos account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw y repo-code:r. Sin account-profile:r, CNB no indica a qué usuario pertenece el token y la cuenta no se puede añadir.", + "cnbTokenHint": "Crea un token de acceso en CNB → Configuración personal → Tokens de acceso con los permisos account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw y repo-code:rw. Sin account-profile:r, CNB no indica a qué usuario pertenece el token y la cuenta no se puede añadir.", "cnbNoAccounts": "Aún no hay ninguna cuenta de CNB", "noAccounts": "No hay cuentas de GitHub configuradas.", "addAccount": "Añadir cuenta", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 7e196e56670..6d6417b5b91 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "Aucun compte Gitea pour l'instant", "cnbTitle": "Comptes CNB", "cnbDescription": "Gérez les comptes CNB (cnb.cool) pour l'authentification. Les jetons sont stockés localement.", - "cnbTokenHint": "Créez un jeton d'accès dans CNB → Paramètres personnels → Jetons d'accès avec les portées account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw et repo-code:r. Sans account-profile:r, CNB n'indique pas à quel utilisateur appartient le jeton et le compte ne peut pas être ajouté.", + "cnbTokenHint": "Créez un jeton d'accès dans CNB → Paramètres personnels → Jetons d'accès avec les portées account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw et repo-code:rw. Sans account-profile:r, CNB n'indique pas à quel utilisateur appartient le jeton et le compte ne peut pas être ajouté.", "cnbNoAccounts": "Aucun compte CNB pour l'instant", "noAccounts": "Aucun compte GitHub configuré.", "addAccount": "Ajouter un compte", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 673b2bfe4d1..67eb625cfba 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "Gitea アカウントがまだありません", "cnbTitle": "CNB アカウント", "cnbDescription": "認証用の CNB(cnb.cool)アカウントを管理します。トークンはローカルに保存されます。", - "cnbTokenHint": "CNB の「個人設定 → アクセストークン」で、account-profile:r、repo-issue:rw、repo-pr:rw、repo-notes:rw、repo-code:r のスコープを付けてアクセストークンを作成してください。account-profile:r がないと CNB はトークンの持ち主を返さないため、アカウントを追加できません。", + "cnbTokenHint": "CNB の「個人設定 → アクセストークン」で、account-profile:r、repo-issue:rw、repo-pr:rw、repo-notes:rw、repo-code:rw のスコープを付けてアクセストークンを作成してください。account-profile:r がないと CNB はトークンの持ち主を返さないため、アカウントを追加できません。", "cnbNoAccounts": "CNB アカウントはまだありません", "noAccounts": "GitHub アカウントが設定されていません。", "addAccount": "アカウントを追加", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 6dbd888b41e..fcb6f28567c 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "아직 Gitea 계정이 없습니다", "cnbTitle": "CNB 계정", "cnbDescription": "인증용 CNB(cnb.cool) 계정을 관리합니다. 토큰은 로컬에 저장됩니다.", - "cnbTokenHint": "CNB의 개인 설정 → 액세스 토큰에서 account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw, repo-code:r 스코프를 포함해 액세스 토큰을 만드세요. account-profile:r이 없으면 CNB가 토큰 소유자를 알려주지 않아 계정을 추가할 수 없습니다.", + "cnbTokenHint": "CNB의 개인 설정 → 액세스 토큰에서 account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw, repo-code:rw 스코프를 포함해 액세스 토큰을 만드세요. account-profile:r이 없으면 CNB가 토큰 소유자를 알려주지 않아 계정을 추가할 수 없습니다.", "cnbNoAccounts": "아직 CNB 계정이 없습니다", "noAccounts": "설정된 GitHub 계정이 없습니다.", "addAccount": "계정 추가", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f9f7c2069fc..9135f96bdf2 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "Ainda não há conta do Gitea", "cnbTitle": "Contas CNB", "cnbDescription": "Gerencie contas CNB (cnb.cool) para autenticação. Os tokens são armazenados localmente.", - "cnbTokenHint": "Crie um token de acesso em CNB → Configurações pessoais → Tokens de acesso com os escopos account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw e repo-code:r. Sem account-profile:r, o CNB não informa a qual usuário o token pertence e a conta não pode ser adicionada.", + "cnbTokenHint": "Crie um token de acesso em CNB → Configurações pessoais → Tokens de acesso com os escopos account-profile:r, repo-issue:rw, repo-pr:rw, repo-notes:rw e repo-code:rw. Sem account-profile:r, o CNB não informa a qual usuário o token pertence e a conta não pode ser adicionada.", "cnbNoAccounts": "Ainda não há conta CNB", "noAccounts": "Nenhuma conta do GitHub configurada.", "addAccount": "Adicionar conta", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 824216ba8e6..265134d1b76 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "还没有 Gitea 账号", "cnbTitle": "CNB 账号", "cnbDescription": "管理用于身份验证的 CNB(cnb.cool)账号。令牌存储在本地。", - "cnbTokenHint": "在 CNB 的「个人设置 → 访问令牌」中创建访问令牌,勾选 account-profile:r、repo-issue:rw、repo-pr:rw、repo-notes:rw 和 repo-code:r 权限。缺少 account-profile:r 时,CNB 不会返回令牌所属用户,账号将无法添加。", + "cnbTokenHint": "在 CNB 的「个人设置 → 访问令牌」中创建访问令牌,勾选 account-profile:r、repo-issue:rw、repo-pr:rw、repo-notes:rw 和 repo-code:rw 权限。缺少 account-profile:r 时,CNB 不会返回令牌所属用户,账号将无法添加。", "cnbNoAccounts": "还没有 CNB 账号", "noAccounts": "暂无 GitHub 账号。", "addAccount": "添加账号", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 4ed2a3f2981..a617770ba6e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -384,7 +384,7 @@ "giteaNoAccounts": "還沒有 Gitea 帳號", "cnbTitle": "CNB 帳號", "cnbDescription": "管理用於身分驗證的 CNB(cnb.cool)帳號。權杖儲存在本機。", - "cnbTokenHint": "在 CNB 的「個人設定 → 存取權杖」中建立存取權杖,勾選 account-profile:r、repo-issue:rw、repo-pr:rw、repo-notes:rw 與 repo-code:r 權限。缺少 account-profile:r 時,CNB 不會回傳權杖所屬使用者,帳號將無法新增。", + "cnbTokenHint": "在 CNB 的「個人設定 → 存取權杖」中建立存取權杖,勾選 account-profile:r、repo-issue:rw、repo-pr:rw、repo-notes:rw 與 repo-code:rw 權限。缺少 account-profile:r 時,CNB 不會回傳權杖所屬使用者,帳號將無法新增。", "cnbNoAccounts": "還沒有 CNB 帳號", "noAccounts": "尚未設定 GitHub 帳號。", "addAccount": "新增帳號",