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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 258 additions & 1 deletion src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use crate::analysis::static_analyzer::registry::all_framework_names;
use crate::analysis::types::{FrameworkCategory, Language, RepoAnalysis};
use crate::api::types::{
AdvisorPoll, AdvisorQueryRequest, AdvisorQueryStarted, AdvisorQueryStatus, ApiErrorResponse,
CanonicalIRFacet, CanonicalIRPayload, CategoriesResponse, FrameworksResponse, HealthResponse,
CanonicalIRFacet, CanonicalIRPayload, CategoriesResponse, ConnectedReposErrorBody,
ConnectedRepository, FrameworksResponse, GetConnectedReposResponse, HealthResponse,
LanguagesResponse, MatchFramework, MatchOptions, MatchProject, MatchRequest, MatchResponse,
TelemetryRequest,
};
Expand Down Expand Up @@ -177,6 +178,53 @@ impl ActualApiClient {
})
}

/// List the repositories connected to `org_id` (`GET /v1/connected-repos`).
/// Lets a caller resolve a repository name to its `repo_unique_id`. The
/// bearer token's own organization must match `org_id`; the server hides
/// other organizations behind a 404 rather than revealing their existence.
pub async fn list_connected_repos(
&self,
org_id: &str,
) -> Result<Vec<ConnectedRepository>, ActualError> {
let url = format!("{}/v1/connected-repos", self.base_url);
let response = self
.authed(self.client.get(&url).query(&[("org_id", org_id)]))
.send()
.await
.map_err(|e| ActualError::ApiError(e.to_string()))?;
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(ActualError::NotLoggedIn);
}
if status == reqwest::StatusCode::FORBIDDEN {
return Err(Self::forbidden_org_error());
}
if !status.is_success() {
return Err(Self::map_connected_repos_error(status, response).await);
}
response
.json::<GetConnectedReposResponse>()
.await
.map(|body| body.repositories)
.map_err(|e| ActualError::ApiError(e.to_string()))
}

/// Map a non-success connected-repos response to an error. This endpoint
/// returns a flat `{error, message}` body (unlike the advisor routes' nested
/// `{error:{code,message}}`), so surface its `message` directly, falling back
/// to the status line when the body is absent, empty, or unparseable.
async fn map_connected_repos_error(
status: reqwest::StatusCode,
response: reqwest::Response,
) -> ActualError {
match response.json::<ConnectedReposErrorBody>().await {
Ok(body) if !body.message.is_empty() => {
ActualError::ApiError(format!("HTTP {status}: {}", body.message))
}
_ => ActualError::ApiError(format!("HTTP {status}")),
}
}

async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, ActualError> {
let url = format!("{}{}", self.base_url, path);
let response = self
Expand Down Expand Up @@ -1838,4 +1886,213 @@ mod tests {
);
mock.assert_async().await;
}

// --- Connected-repos client tests ---

const CONNECTED_REPOS_BODY: &str = r#"{
"repositories": [
{
"repo_unique_id": "33333333-3333-3333-3333-333333333333",
"name": "actual-cli",
"external_owner": "actual-software",
"url": "https://github.com/actual-software/actual-cli"
},
{
"repo_unique_id": "44444444-4444-4444-4444-444444444444",
"name": "web-app",
"external_owner": "actual-software",
"url": "https://github.com/actual-software/web-app"
}
]
}"#;

#[tokio::test]
async fn test_list_connected_repos_success_sends_bearer_and_org_id() {
let mut server = mockito::Server::new_async().await;
let org = "11111111-1111-1111-1111-111111111111";
let mock = server
.mock("GET", "/v1/connected-repos")
.match_header("authorization", "Bearer tok-123")
.match_query(mockito::Matcher::UrlEncoded("org_id".into(), org.into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(CONNECTED_REPOS_BODY)
.create_async()
.await;
let client = ActualApiClient::new(&server.url())
.unwrap()
.with_bearer("tok-123");
let repos = client.list_connected_repos(org).await.unwrap();
assert_eq!(repos.len(), 2);
assert_eq!(repos[0].name, "actual-cli");
assert_eq!(
repos[0].repo_unique_id,
"33333333-3333-3333-3333-333333333333"
);
assert_eq!(repos[0].external_owner, "actual-software");
assert_eq!(repos[1].name, "web-app");
mock.assert_async().await;
}

#[tokio::test]
async fn test_list_connected_repos_empty_list() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/v1/connected-repos")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"repositories":[]}"#)
.create_async()
.await;
let client = ActualApiClient::new(&server.url())
.unwrap()
.with_bearer("t");
let repos = client
.list_connected_repos("11111111-1111-1111-1111-111111111111")
.await
.unwrap();
assert!(repos.is_empty());
mock.assert_async().await;
}

#[tokio::test]
async fn test_list_connected_repos_unauthorized_maps_to_not_logged_in() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/v1/connected-repos")
.match_query(mockito::Matcher::Any)
.with_status(401)
.with_body(r#"{"error":"unauthorized","message":"authentication required"}"#)
.create_async()
.await;
let client = ActualApiClient::new(&server.url())
.unwrap()
.with_bearer("bad");
let err = client.list_connected_repos("org").await.unwrap_err();
assert!(matches!(err, ActualError::NotLoggedIn));
mock.assert_async().await;
}

#[tokio::test]
async fn test_list_connected_repos_forbidden_maps_to_org_mismatch() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/v1/connected-repos")
.match_query(mockito::Matcher::Any)
.with_status(403)
.with_body(r#"{"error":"forbidden","message":"cross-org"}"#)
.create_async()
.await;
let client = ActualApiClient::new(&server.url())
.unwrap()
.with_bearer("t");
let err = client.list_connected_repos("org").await.unwrap_err();
assert!(
matches!(err, ActualError::OrgMismatch { .. }),
"expected OrgMismatch for 403, got {err:?}"
);
mock.assert_async().await;
}

#[tokio::test]
async fn test_list_connected_repos_not_found_surfaces_flat_message() {
let mut server = mockito::Server::new_async().await;
// The connected-repos endpoint returns a flat {error, message} body; the
// human-readable message is surfaced (map_connected_repos_error message arm).
let mock = server
.mock("GET", "/v1/connected-repos")
.match_query(mockito::Matcher::Any)
.with_status(404)
.with_header("content-type", "application/json")
.with_body(
r#"{"error":"not_found","message":"organization not found or not accessible"}"#,
)
.create_async()
.await;
let client = ActualApiClient::new(&server.url())
.unwrap()
.with_bearer("t");
let err = client.list_connected_repos("org").await.unwrap_err();
assert!(
matches!(err, ActualError::ApiError(ref m) if m.contains("404") && m.contains("organization not found")),
"got: {err:?}"
);
mock.assert_async().await;
}

#[tokio::test]
async fn test_list_connected_repos_empty_message_falls_back_to_status() {
let mut server = mockito::Server::new_async().await;
// A parseable body with a blank message → the guard is false, so the
// status-only fallback arm is taken.
let mock = server
.mock("GET", "/v1/connected-repos")
.match_query(mockito::Matcher::Any)
.with_status(400)
.with_header("content-type", "application/json")
.with_body(r#"{"error":"bad_request","message":""}"#)
.create_async()
.await;
let client = ActualApiClient::new(&server.url())
.unwrap()
.with_bearer("t");
let err = client.list_connected_repos("org").await.unwrap_err();
assert!(
matches!(err, ActualError::ApiError(ref m) if m.contains("400")),
"got: {err:?}"
);
mock.assert_async().await;
}

#[tokio::test]
async fn test_list_connected_repos_unparseable_error_body_falls_back_to_status() {
let mut server = mockito::Server::new_async().await;
// A non-JSON error body → the parse fails and the status-only fallback
// arm is taken (map_connected_repos_error catch-all).
let mock = server
.mock("GET", "/v1/connected-repos")
.match_query(mockito::Matcher::Any)
.with_status(500)
.with_body("boom")
.create_async()
.await;
let client = ActualApiClient::new(&server.url())
.unwrap()
.with_bearer("t");
let err = client.list_connected_repos("org").await.unwrap_err();
assert!(
matches!(err, ActualError::ApiError(ref m) if m.contains("500")),
"got: {err:?}"
);
mock.assert_async().await;
}

#[tokio::test]
async fn test_list_connected_repos_success_unparseable_body_is_api_error() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/v1/connected-repos")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body("not valid json")
.create_async()
.await;
let client = ActualApiClient::new(&server.url())
.unwrap()
.with_bearer("t");
let err = client.list_connected_repos("org").await.unwrap_err();
assert!(matches!(err, ActualError::ApiError(_)), "got: {err:?}");
mock.assert_async().await;
}

#[tokio::test]
async fn test_list_connected_repos_network_error() {
let client = ActualApiClient::new("http://127.0.0.1:1")
.unwrap()
.with_bearer("t");
let err = client.list_connected_repos("org").await.unwrap_err();
assert!(matches!(err, ActualError::ApiError(_)));
}
}
76 changes: 76 additions & 0 deletions src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,38 @@ pub struct HealthResponse {
pub version: String,
}

// --- Connected-repos types (GET /v1/connected-repos) ---

/// One repository connected to an organization. Fields are snake_case to match
/// the other CLI-facing (advisor) response bodies. Lets a client map a
/// repository `name` (or `external_owner`/`name`) to its `repo_unique_id`.
/// Extra server fields are ignored.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ConnectedRepository {
pub repo_unique_id: String,
pub name: String,
pub external_owner: String,
pub url: String,
}

/// Response body for `GET /v1/connected-repos`. `repositories` defaults to empty
/// when the org has none connected.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct GetConnectedReposResponse {
#[serde(default)]
pub repositories: Vec<ConnectedRepository>,
}

/// Flat error body returned by `GET /v1/connected-repos` (`{error, message}`),
/// distinct from the advisor routes' nested [`ApiErrorResponse`]. Only the
/// human-readable `message` is consumed (the sibling `error` code is ignored);
/// it defaults to empty so a partial or unexpected body still deserializes.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ConnectedReposErrorBody {
#[serde(default)]
pub message: String,
}

// --- Telemetry types (POST /counter/record) ---

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -1068,4 +1100,48 @@ mod tests {
assert_eq!(out.interpreter.related_adrs[0].url, None);
assert_eq!(out.interpreter.related_adrs[1].url, None);
}

#[test]
fn test_connected_repos_response_deserializes_all_fields() {
let json = r#"{
"repositories": [
{
"repo_unique_id": "33333333-3333-3333-3333-333333333333",
"name": "actual-cli",
"external_owner": "actual-software",
"url": "https://github.com/actual-software/actual-cli"
}
]
}"#;
let resp: GetConnectedReposResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.repositories.len(), 1);
let repo = &resp.repositories[0];
assert_eq!(repo.repo_unique_id, "33333333-3333-3333-3333-333333333333");
assert_eq!(repo.name, "actual-cli");
assert_eq!(repo.external_owner, "actual-software");
assert_eq!(repo.url, "https://github.com/actual-software/actual-cli");
}

#[test]
fn test_connected_repos_response_missing_repositories_defaults_empty() {
// A body without `repositories` deserializes to an empty list (the
// `#[serde(default)]` path), not an error.
let resp: GetConnectedReposResponse = serde_json::from_str("{}").unwrap();
assert!(resp.repositories.is_empty());
}

#[test]
fn test_connected_repos_error_body_reads_message() {
let body: ConnectedReposErrorBody =
serde_json::from_str(r#"{"error":"not_found","message":"organization not found"}"#)
.unwrap();
assert_eq!(body.message, "organization not found");
}

#[test]
fn test_connected_repos_error_body_missing_message_defaults_empty() {
// Absent `message` (and an ignored, unknown `error` code) defaults to "".
let body: ConnectedReposErrorBody = serde_json::from_str("{}").unwrap();
assert_eq!(body.message, "");
}
}
11 changes: 7 additions & 4 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,13 @@ pub struct AdvisorArgs {
#[arg(long, value_name = "ORG_ID")]
pub org: Option<String>,

/// Scope the query to a specific connected repository (UUID). When omitted,
/// the query runs at org level. Auto-detection from the git remote is not
/// available yet — it requires the connected-repos API.
#[arg(long, value_name = "REPO_ID")]
/// Scope the query to a specific connected repository, given either as its
/// name (e.g. `actual-cli`, or `owner/actual-cli` to disambiguate a name
/// shared across owners) or as its UUID. A name is resolved to a repo id via
/// the connected-repos API; an unrecognized name fails with the list of
/// repositories you can choose from. When omitted, the query runs at org
/// level.
#[arg(long, value_name = "REPO")]
pub repo: Option<String>,
}

Expand Down
Loading
Loading