@@ -35,6 +35,12 @@ impl CodSpeedAPIClient {
3535 }
3636 }
3737
38+ /// Returns a client that uses `token` for authentication, regardless of
39+ /// the token this client was built with.
40+ pub fn with_token ( & self , token : String ) -> Self {
41+ Self :: new ( Some ( token) , self . api_url . clone ( ) )
42+ }
43+
3844 /// The token this client currently authenticates with, if any.
3945 ///
4046 /// Note: this is not necessarily the token the client was built with —
@@ -302,47 +308,141 @@ nest! {
302308 }
303309}
304310
305- #[ derive( Serialize , Clone ) ]
311+ nest ! {
312+ #[ derive( Debug , Deserialize , Serialize , Clone ) ] *
313+ #[ serde( rename_all = "camelCase" ) ] *
314+ pub struct SessionPayload {
315+ pub user: Option <pub struct SessionUser {
316+ pub login: String ,
317+ pub provider: RepositoryProvider ,
318+ } >,
319+ }
320+ }
321+
322+ #[ derive( Debug , Deserialize , Serialize ) ]
306323#[ serde( rename_all = "camelCase" ) ]
307- pub struct GetRepositoryVars {
324+ struct SessionData {
325+ session : SessionPayload ,
326+ }
327+
328+ /// Outcome of [`CodSpeedAPIClient::session`]. The CLI distinguishes
329+ /// "no/expired token" from any other error so it can render a clear message.
330+ pub enum SessionError {
331+ /// Token is missing or no longer valid.
332+ Unauthenticated ,
333+ /// Anything else (network, server error, etc).
334+ Other ( anyhow:: Error ) ,
335+ }
336+
337+ #[ derive( Debug , Deserialize , Serialize , Clone ) ]
338+ #[ serde( rename_all = "camelCase" ) ]
339+ pub struct RepositoryOverviewPayload {
308340 pub owner : String ,
309341 pub name : String ,
310342 pub provider : RepositoryProvider ,
343+ pub has_write_access : bool ,
311344}
312345
313- nest ! {
314- #[ derive( Debug , Deserialize , Serialize , Clone ) ] *
315- #[ serde( rename_all = "camelCase" ) ] *
316- struct GetRepositoryData {
317- repository_overview: Option <pub struct GetRepositoryPayload {
318- pub id: String ,
319- } >,
320- user: Option <pub struct GetRepositoryUser {
321- pub id: String ,
322- } >,
323- }
346+ #[ derive( Serialize , Clone ) ]
347+ #[ serde( rename_all = "camelCase" ) ]
348+ pub struct SessionAndRepositoryOverviewVars {
349+ pub owner : String ,
350+ pub name : String ,
351+ pub provider : Option < RepositoryProvider > ,
324352}
325353
326- nest ! {
327- #[ derive( Debug , Deserialize , Serialize ) ] *
328- #[ serde( rename_all = "camelCase" ) ] *
329- struct CurrentUserData {
330- user: Option <pub struct CurrentUserPayload {
331- pub login: String ,
332- pub provider: RepositoryProvider ,
333- } >,
334- }
354+ #[ derive( Debug , Deserialize , Serialize ) ]
355+ #[ serde( rename_all = "camelCase" ) ]
356+ struct SessionAndRepositoryOverviewData {
357+ session : SessionPayload ,
358+ repository_overview : Option < RepositoryOverviewPayload > ,
359+ }
360+
361+ pub struct SessionAndRepositoryOverview {
362+ pub session : SessionPayload ,
363+ pub repository_overview : Option < RepositoryOverviewPayload > ,
364+ }
365+
366+ /// Outcome of [`CodSpeedAPIClient::session_and_repository_overview`]. A
367+ /// missing repository is folded into the success path (`repository_overview`
368+ /// becomes `None`); only a missing/expired token or a transport-level
369+ /// failure surfaces here.
370+ pub enum SessionAndRepositoryOverviewError {
371+ Unauthenticated ,
372+ Other ( anyhow:: Error ) ,
335373}
336374
337375impl CodSpeedAPIClient {
338- pub async fn get_current_user ( & self ) -> Result < Option < CurrentUserPayload > > {
376+ /// Introspect the token currently configured on this client.
377+ ///
378+ /// Returns the linked user (when applicable). Used to verify a token's
379+ /// validity without conflating it with repository-level access checks —
380+ /// those are done with [`Self::session_and_repository_overview`].
381+ pub async fn session ( & self ) -> std:: result:: Result < SessionPayload , SessionError > {
339382 let response = self
340383 . gql_client
341- . query_unwrap :: < CurrentUserData > ( include_str ! ( "queries/CurrentUser .gql" ) )
384+ . query_unwrap :: < SessionData > ( include_str ! ( "queries/Session .gql" ) )
342385 . await ;
343386 match response {
344- Ok ( data) => Ok ( data. user ) ,
345- Err ( err) => bail ! ( "Failed to get current user: {err}" ) ,
387+ Ok ( data) => Ok ( data. session ) ,
388+ Err ( err) if err. contains_error_code ( "UNAUTHENTICATED" ) => {
389+ Err ( SessionError :: Unauthenticated )
390+ }
391+ Err ( err) => Err ( SessionError :: Other ( anyhow ! (
392+ "Failed to validate token: {err}"
393+ ) ) ) ,
394+ }
395+ }
396+
397+ /// Validate the token and look up a candidate repository in one
398+ /// round-trip. Used by `auth status` (with a detected git remote) and
399+ /// the up-front check in `run`/`exec`.
400+ ///
401+ /// `repositoryOverview` is nullable in the schema, so a missing
402+ /// repository surfaces as `repository_overview: None` on the success
403+ /// path. The server still returns a `REPOSITORY_NOT_FOUND` error in
404+ /// that case to avoid leaking existence info, but the partial-data
405+ /// payload carries the `session` field — we deserialize it from the
406+ /// error and treat the call as successful for the session's purposes.
407+ pub async fn session_and_repository_overview (
408+ & self ,
409+ vars : SessionAndRepositoryOverviewVars ,
410+ ) -> std:: result:: Result < SessionAndRepositoryOverview , SessionAndRepositoryOverviewError > {
411+ let response = self
412+ . gql_client
413+ . query_with_vars_unwrap :: <
414+ SessionAndRepositoryOverviewData ,
415+ SessionAndRepositoryOverviewVars ,
416+ > (
417+ include_str ! ( "queries/SessionAndRepositoryOverview.gql" ) ,
418+ vars,
419+ )
420+ . await ;
421+ match response {
422+ Ok ( data) => Ok ( SessionAndRepositoryOverview {
423+ session : data. session ,
424+ repository_overview : data. repository_overview ,
425+ } ) ,
426+ Err ( err) if err. contains_error_code ( "UNAUTHENTICATED" ) => {
427+ Err ( SessionAndRepositoryOverviewError :: Unauthenticated )
428+ }
429+ Err ( err) if err. contains_error_code ( "REPOSITORY_NOT_FOUND" ) => {
430+ match err. data :: < SessionAndRepositoryOverviewData > ( ) {
431+ Some ( Ok ( data) ) => Ok ( SessionAndRepositoryOverview {
432+ session : data. session ,
433+ repository_overview : None ,
434+ } ) ,
435+ Some ( Err ( decode_err) ) => Err ( SessionAndRepositoryOverviewError :: Other (
436+ anyhow ! ( "Failed to deserialize partial response data: {decode_err}" ) ,
437+ ) ) ,
438+ None => Err ( SessionAndRepositoryOverviewError :: Other ( anyhow ! (
439+ "Server returned REPOSITORY_NOT_FOUND without partial data: {err}"
440+ ) ) ) ,
441+ }
442+ }
443+ Err ( err) => Err ( SessionAndRepositoryOverviewError :: Other ( anyhow ! (
444+ "Failed to validate token and repository: {err}"
445+ ) ) ) ,
346446 }
347447 }
348448
@@ -442,38 +542,6 @@ impl CodSpeedAPIClient {
442542 Err ( err) => bail ! ( "Failed to get or create project repository: {err}" ) ,
443543 }
444544 }
445-
446- /// Check if a repository exists in CodSpeed.
447- /// Returns Some(payload) if the repository exists, None otherwise.
448- pub async fn get_repository (
449- & self ,
450- vars : GetRepositoryVars ,
451- ) -> Result < Option < GetRepositoryPayload > > {
452- let response = self
453- . gql_client
454- . query_with_vars_unwrap :: < GetRepositoryData , GetRepositoryVars > (
455- include_str ! ( "queries/GetRepository.gql" ) ,
456- vars. clone ( ) ,
457- )
458- . await ;
459- match response {
460- Ok ( response) => {
461- if response. user . is_none ( ) {
462- bail ! (
463- "Your session has expired, please login again using `codspeed auth login`"
464- ) ;
465- }
466- Ok ( response. repository_overview )
467- }
468- Err ( err) if err. contains_error_code ( "REPOSITORY_NOT_FOUND" ) => Ok ( None ) ,
469- Err ( err) if err. contains_error_code ( "UNAUTHENTICATED" ) => {
470- bail ! ( "Your session has expired, please login again using `codspeed auth login`" )
471- }
472- Err ( err) => {
473- bail ! ( "Failed to get repository: {err}" )
474- }
475- }
476- }
477545}
478546
479547impl CodSpeedAPIClient {
0 commit comments