Feat/open projects config to any (#681)
* feat(server): derive owner/repo display name for any remote host Generalize deriveProjectGroupingName so any remote:<host>/<segments...> project key returns the last two path segments (owner/repo) instead of just the trailing segment. Path-fallback for non-remote keys is unchanged. Brings GitLab, Gitea, Bitbucket, and self-hosted remotes to parity with the prior github.com-only behavior — no separate special-case needed. * feat(app): show projects from any git remote in Projects settings Remove the isSupportedProjectKey filter so workspaces with non-GitHub remotes (GitLab, Gitea, Bitbucket, self-hosted, ssh-style) appear in the Projects list and route to the project settings screen. The daemon RPCs, config schema, and registry were already host-agnostic; this lifts a client-side filter that hid them. Also remove the now-vestigial hiddenUnsupportedRemoteCount field from ProjectSummary, BuildProjectsResult, and UseProjectsResult — once the filter is gone, the count is always zero and the field is dead state. This is an internal app-package type, not a wire schema, so deletion is safe. * chore(app): simplify Projects empty state to "No projects yet" Drop the "Non-GitHub remote projects aren't supported yet" empty-state branch — it can no longer fire now that any git remote is shown. The empty state is unconditional now. * test(app): cover non-GitHub remote in projects-settings e2e Add a fixture that creates a temp git repo with origin pointing at a gitlab.com URL and exercises the same paseo.json read/edit/save flow already covered for local repos. Verifies the project surfaces with the "acme/app" display name and that the round-trip persists correctly. Extracted the remote-setup branch in createTempGitRepo into a small configureRemote helper to keep the main function under the cyclomatic complexity limit. --------- Co-authored-by: Mathias Kurz <mkurz@stamus-networks.com>
This commit is contained in:
co-authored by
Mathias Kurz
parent
2b6d7d4ec2
commit
44d13919d7
@@ -252,7 +252,6 @@ describe("useProjects", () => {
|
||||
});
|
||||
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hiddenUnsupportedRemoteCount",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface ProjectHostError {
|
||||
|
||||
export interface UseProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
hostErrors: ProjectHostError[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
@@ -127,7 +126,6 @@ export function useProjects(): UseProjectsResult {
|
||||
|
||||
return {
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
|
||||
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||
isLoading: projectsQuery.isLoading,
|
||||
isFetching: projectsQuery.isFetching,
|
||||
|
||||
@@ -66,7 +66,6 @@ const { theme, projectsState, hostState, navigate, confirmDialogMock, client } =
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [] as ProjectHostError[],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -442,7 +441,6 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount: hosts.filter((host) => host.isOnline).length,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -451,7 +449,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
|
||||
@@ -32,7 +32,6 @@ const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -231,7 +230,6 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -240,7 +238,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -343,19 +340,12 @@ describe("ProjectsScreen", () => {
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no projects", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
|
||||
setProjectsState({ projects: [] });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("No projects yet");
|
||||
});
|
||||
|
||||
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
|
||||
expect(container?.textContent).not.toContain("Non-GitHub remote projects aren't supported yet");
|
||||
});
|
||||
|
||||
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ProjectsScreenProps {
|
||||
}
|
||||
|
||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
|
||||
const { projects, hostErrors, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
|
||||
if (isLoading && projects.length === 0) {
|
||||
@@ -27,13 +27,9 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
const message =
|
||||
hiddenUnsupportedRemoteCount > 0
|
||||
? "Non-GitHub remote projects aren't supported yet"
|
||||
: "No projects yet";
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<Text style={styles.emptyText}>{message}</Text>
|
||||
<Text style={styles.emptyText}>No projects yet</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ describe("buildProjects", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters non-GitHub remote projects while keeping GitHub and local projects", () => {
|
||||
it("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
@@ -368,20 +368,30 @@ describe("buildProjects", () => {
|
||||
id: "github",
|
||||
repoRoot: "/repo/github",
|
||||
project: placement({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
projectKey: "remote:github.com/acme/web",
|
||||
projectName: "acme/web",
|
||||
cwd: "/repo/github",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
remoteUrl: "https://github.com/acme/web.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "gitlab",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "app",
|
||||
projectKey: "remote:gitlab.com/acme/api",
|
||||
projectName: "acme/api",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
remoteUrl: "https://gitlab.com/acme/api.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "bitbucket",
|
||||
repoRoot: "/repo/bitbucket",
|
||||
project: placement({
|
||||
projectKey: "remote:bitbucket.org/acme/cli",
|
||||
projectName: "acme/cli",
|
||||
cwd: "/repo/bitbucket",
|
||||
remoteUrl: "https://bitbucket.org/acme/cli.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
@@ -399,14 +409,15 @@ describe("buildProjects", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
|
||||
expect(result.projects.map((project) => project.projectKey)).toEqual([
|
||||
"remote:github.com/acme/app",
|
||||
"remote:gitlab.com/acme/api",
|
||||
"remote:bitbucket.org/acme/cli",
|
||||
"remote:github.com/acme/web",
|
||||
"/repo/local",
|
||||
]);
|
||||
});
|
||||
|
||||
it("produces the unsupported empty-state signal when only non-GitHub remote projects are present", () => {
|
||||
it("renders a non-GitHub remote project on its own when no other projects are present", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
@@ -419,7 +430,7 @@ describe("buildProjects", () => {
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
@@ -429,8 +440,48 @@ describe("buildProjects", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toEqual([]);
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(1);
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app");
|
||||
expect(result.projects[0]?.projectName).toBe("acme/app");
|
||||
expect(result.projects[0]?.githubUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => {
|
||||
const result = buildProjects({
|
||||
hosts: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
isOnline: true,
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
workspace({
|
||||
id: "feature",
|
||||
repoRoot: "/repo/gitlab",
|
||||
project: placement({
|
||||
projectKey: "remote:gitlab.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/gitlab/feature",
|
||||
remoteUrl: "https://gitlab.com/acme/app.git",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts).toHaveLength(1);
|
||||
expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => {
|
||||
@@ -461,6 +512,5 @@ describe("buildProjects", () => {
|
||||
expect(summary?.hosts).toHaveLength(1);
|
||||
expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy");
|
||||
expect(summary?.hosts[0]?.workspaceCount).toBe(1);
|
||||
expect(result.hiddenUnsupportedRemoteCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface ProjectSummary {
|
||||
hostCount: number;
|
||||
onlineHostCount: number;
|
||||
githubUrl?: string;
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}
|
||||
|
||||
export interface ProjectHost {
|
||||
@@ -43,7 +42,6 @@ export interface BuildProjectsInput {
|
||||
|
||||
export interface BuildProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}
|
||||
|
||||
const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
|
||||
@@ -61,10 +59,6 @@ interface ProjectGroup {
|
||||
hostsByServerId: Map<string, HostGroup>;
|
||||
}
|
||||
|
||||
function isSupportedProjectKey(projectKey: string): boolean {
|
||||
return !projectKey.startsWith("remote:") || projectKey.startsWith("remote:github.com/");
|
||||
}
|
||||
|
||||
function deriveGithubUrl(projectKey: string): string | undefined {
|
||||
const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN);
|
||||
if (!match) {
|
||||
@@ -118,38 +112,27 @@ function compareHosts(left: ProjectHostEntry, right: ProjectHostEntry): number {
|
||||
return left.serverId.localeCompare(right.serverId);
|
||||
}
|
||||
|
||||
function toProjectSummary(input: {
|
||||
draft: ProjectGroup;
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
}): ProjectSummary {
|
||||
const hosts = Array.from(input.draft.hostsByServerId.values())
|
||||
.map(toHostEntry)
|
||||
.sort(compareHosts);
|
||||
function toProjectSummary(draft: ProjectGroup): ProjectSummary {
|
||||
const hosts = Array.from(draft.hostsByServerId.values()).map(toHostEntry).sort(compareHosts);
|
||||
const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
|
||||
const onlineHostCount = hosts.filter((host) => host.isOnline).length;
|
||||
return {
|
||||
projectKey: input.draft.projectKey,
|
||||
projectName: input.draft.projectName,
|
||||
projectKey: draft.projectKey,
|
||||
projectName: draft.projectName,
|
||||
hosts,
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: deriveGithubUrl(input.draft.projectKey),
|
||||
hiddenUnsupportedRemoteCount: input.hiddenUnsupportedRemoteCount,
|
||||
githubUrl: deriveGithubUrl(draft.projectKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
const groups = new Map<string, ProjectGroup>();
|
||||
let hiddenUnsupportedRemoteCount = 0;
|
||||
|
||||
for (const host of input.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
const projectKey = workspace.projectId;
|
||||
if (!isSupportedProjectKey(projectKey)) {
|
||||
hiddenUnsupportedRemoteCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let group = groups.get(projectKey);
|
||||
if (!group) {
|
||||
@@ -175,9 +158,7 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
}
|
||||
}
|
||||
|
||||
const projects = Array.from(groups.values()).map((draft) =>
|
||||
toProjectSummary({ draft, hiddenUnsupportedRemoteCount }),
|
||||
);
|
||||
const projects = Array.from(groups.values()).map(toProjectSummary);
|
||||
projects.sort((left, right) => {
|
||||
const name = left.projectName.localeCompare(right.projectName);
|
||||
if (name !== 0) {
|
||||
@@ -186,8 +167,5 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
|
||||
return left.projectKey.localeCompare(right.projectKey);
|
||||
});
|
||||
|
||||
return {
|
||||
projects,
|
||||
hiddenUnsupportedRemoteCount,
|
||||
};
|
||||
return { projects };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user