cli/src/workspace.rs
Mahmoud Abdelwahab 8b13b75256
fix: dedupe workspaces in list output (#852)
Workspaces returned by `me.workspaces` and `externalWorkspaces` can
overlap when a user has both workspace membership and project-level
access to the same workspace, causing duplicates in `railway list`,
`railway project list`, and `railway whoami --json`. Dedupe by id
while merging, preferring the Member variant (richer data).

Closes #844, #831.
2026-04-21 15:52:26 +02:00

164 lines
4.6 KiB
Rust

use chrono::{DateTime, Utc};
use serde::Serialize;
use std::collections::HashSet;
use std::fmt::Display;
use super::{
queries::user_projects::{
UserProjectsExternalWorkspaces, UserProjectsExternalWorkspacesProjects,
UserProjectsMeWorkspaces, UserProjectsMeWorkspacesProjectsEdgesNode,
},
*,
};
pub async fn workspaces() -> Result<Vec<Workspace>> {
let configs = Configs::new()?;
let vars = queries::user_projects::Variables {};
let client = GQLClient::new_authorized(&configs)?;
let response =
post_graphql::<queries::UserProjects, _>(&client, configs.get_backboard(), vars).await?;
// Member variants are yielded first so that a workspace the user both owns
// and is an external member of keeps the richer Member representation.
let mut seen: HashSet<String> = HashSet::new();
let mut workspaces: Vec<Workspace> = response
.me
.workspaces
.into_iter()
.map(Workspace::Member)
.chain(
response
.external_workspaces
.into_iter()
.map(Workspace::External),
)
.filter(|w| seen.insert(w.id().to_string()))
.collect();
workspaces.sort_by(|a, b| b.id().cmp(a.id()));
Ok(workspaces)
}
#[derive(Debug, Clone)]
pub enum Workspace {
External(UserProjectsExternalWorkspaces),
Member(UserProjectsMeWorkspaces),
}
impl Workspace {
pub fn id(&self) -> &str {
match self {
Self::External(w) => w.id.as_str(),
Self::Member(w) => w.id.as_str(),
}
}
pub fn name(&self) -> &str {
match self {
Self::External(w) => w.name.as_str(),
Self::Member(w) => w.name.as_str(),
}
}
#[allow(deprecated)] // team field deprecated but needed for backwards compat with scripts using team IDs
pub fn team_id(&self) -> Option<&str> {
match self {
Self::External(w) => w.team_id.as_deref(),
Self::Member(w) => w.team.as_ref().map(|t| t.id.as_str()),
}
}
pub fn projects(&self) -> Vec<Project> {
let mut projects: Vec<_> = match self {
Self::External(w) => w.projects.iter().cloned().map(Project::External).collect(),
Self::Member(w) => w
.projects
.edges
.iter()
.cloned()
.map(|e| Project::Workspace(e.node))
.collect(),
};
projects.sort_by_key(|b| std::cmp::Reverse(b.updated_at()));
projects
}
pub fn projects_with_workspace(&self) -> Vec<ProjectWithWorkspace> {
let workspace_info = WorkspaceInfo {
id: self.id().to_string(),
name: self.name().to_string(),
};
self.projects()
.into_iter()
.map(|project| ProjectWithWorkspace {
workspace: workspace_info.clone(),
project,
})
.collect()
}
}
impl Display for Workspace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::External(w) => w.name.as_str(),
Self::Member(w) => w.name.as_str(),
};
write!(f, "{name}")
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum Project {
External(UserProjectsExternalWorkspacesProjects),
Workspace(UserProjectsMeWorkspacesProjectsEdgesNode),
}
impl Project {
pub fn id(&self) -> &str {
match self {
Self::External(w) => &w.id,
Self::Workspace(w) => &w.id,
}
}
pub fn name(&self) -> &str {
match self {
Self::External(w) => &w.name,
Self::Workspace(w) => &w.name,
}
}
pub fn updated_at(&self) -> DateTime<Utc> {
match self {
Self::External(w) => w.updated_at,
Self::Workspace(w) => w.updated_at,
}
}
pub fn deleted_at(&self) -> Option<DateTime<Utc>> {
match self {
Self::External(w) => w.deleted_at,
Self::Workspace(w) => w.deleted_at,
}
}
}
impl Display for Project {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Workspace(project) => write!(f, "{}", project.name),
Self::External(project) => write!(f, "{}", project.name),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceInfo {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectWithWorkspace {
pub workspace: WorkspaceInfo,
#[serde(flatten)]
pub project: Project,
}