diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index dcf52dc17..42cff6b0e 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -499,7 +499,7 @@ platformHost={r.platformHost} repoOwner={r.owner} repoName={r.name} - repoPath={`${r.owner}/${r.name}`} + repoPath={r.repoPath} ownerItemType={r.itemType === "issue" ? "issue" : "pull_request"} ownerItemNumber={r.number} associatedPRNumber={r.itemType === "pr" ? r.number : null} diff --git a/frontend/src/lib/stores/router.svelte.ts b/frontend/src/lib/stores/router.svelte.ts index 9edde7c08..0d944fde0 100644 --- a/frontend/src/lib/stores/router.svelte.ts +++ b/frontend/src/lib/stores/router.svelte.ts @@ -46,6 +46,7 @@ export type Route = provider: string; itemType: "pr" | "issue"; platformHost: string; + repoPath: string; owner: string; name: string; number: number; @@ -149,6 +150,15 @@ function inferLegacyEmbedProvider(platformHost: string): string { return platformHost.toLowerCase().includes("gitlab") ? "gitlab" : "github"; } +function splitRepoPath(repoPath: string): { owner: string; name: string } | undefined { + const pathParts = repoPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean); + if (pathParts.length < 2) return undefined; + return { + owner: pathParts.slice(0, -1).join("/"), + name: pathParts[pathParts.length - 1]!, + }; +} + function parseRoute(fullPath: string): Route { const qIdx = fullPath.indexOf("?"); const pathname = qIdx >= 0 ? fullPath.slice(0, qIdx) : fullPath; @@ -274,10 +284,15 @@ function parseRoute(fullPath: string): Route { }; } const embedDetailMatch = path.match( - /^\/workspaces\/embed\/detail\/([^/]+)\/(pr|issue)\/([^/]+)\/([^/]+)\/([^/]+)\/(\d+)$/, + /^\/workspaces\/embed\/detail\/([^/]+)\/(pr|issue)\/([^/]+)\/(\d+)$/, ); if (embedDetailMatch) { const sp = new URLSearchParams(search); + const repoPath = sp.get("repo_path")?.trim(); + const repo = repoPath ? splitRepoPath(repoPath) : undefined; + if (!repoPath || !repo) { + return { page: "workspaces" }; + } const branch = sp.get("branch") ?? undefined; const tabParam = sp.get("tab"); const tab: EmbedDetailTab | undefined = @@ -289,9 +304,37 @@ function parseRoute(fullPath: string): Route { provider: embedDetailMatch[1]!, itemType: embedDetailMatch[2] as "pr" | "issue", platformHost: embedDetailMatch[3]!, - owner: embedDetailMatch[4]!, - name: embedDetailMatch[5]!, - number: parseInt(embedDetailMatch[6]!, 10), + repoPath, + owner: repo.owner, + name: repo.name, + number: parseInt(embedDetailMatch[4]!, 10), + }; + if (branch) r.branch = branch; + if (tab) r.tab = tab; + return r; + } + const legacyProviderEmbedDetailMatch = path.match( + /^\/workspaces\/embed\/detail\/([^/]+)\/(pr|issue)\/([^/]+)\/([^/]+)\/([^/]+)\/(\d+)$/, + ); + if (legacyProviderEmbedDetailMatch) { + const sp = new URLSearchParams(search); + const branch = sp.get("branch") ?? undefined; + const tabParam = sp.get("tab"); + const tab: EmbedDetailTab | undefined = + tabParam === "pr" || tabParam === "issue" || tabParam === "reviews" + ? tabParam + : undefined; + const owner = legacyProviderEmbedDetailMatch[4]!; + const name = legacyProviderEmbedDetailMatch[5]!; + const r: Route = { + page: "embed-workspace-detail", + provider: legacyProviderEmbedDetailMatch[1]!, + itemType: legacyProviderEmbedDetailMatch[2] as "pr" | "issue", + platformHost: legacyProviderEmbedDetailMatch[3]!, + repoPath: `${owner}/${name}`, + owner, + name, + number: parseInt(legacyProviderEmbedDetailMatch[6]!, 10), }; if (branch) r.branch = branch; if (tab) r.tab = tab; @@ -309,13 +352,16 @@ function parseRoute(fullPath: string): Route { ? tabParam : undefined; const platformHost = legacyEmbedDetailMatch[2]!; + const owner = legacyEmbedDetailMatch[3]!; + const name = legacyEmbedDetailMatch[4]!; const r: Route = { page: "embed-workspace-detail", provider: inferLegacyEmbedProvider(platformHost), itemType: legacyEmbedDetailMatch[1] as "pr" | "issue", platformHost, - owner: legacyEmbedDetailMatch[3]!, - name: legacyEmbedDetailMatch[4]!, + repoPath: `${owner}/${name}`, + owner, + name, number: parseInt(legacyEmbedDetailMatch[5]!, 10), }; if (branch) r.branch = branch; diff --git a/frontend/src/lib/stores/router.test.ts b/frontend/src/lib/stores/router.test.ts index 2a7de50d3..975a2c102 100644 --- a/frontend/src/lib/stores/router.test.ts +++ b/frontend/src/lib/stores/router.test.ts @@ -245,15 +245,16 @@ describe("router embed-workspace routes", () => { }); }); - it("parses /workspaces/embed/detail/:provider/pr/:host/:owner/:name/:number", () => { + it("parses /workspaces/embed/detail/:provider/pr/:host/:number with repo_path", () => { navigate( - "/workspaces/embed/detail/github/pr/github.com/acme/widgets/42", + "/workspaces/embed/detail/github/pr/github.com/42?repo_path=acme%2Fwidgets", ); expect(getRoute()).toEqual({ page: "embed-workspace-detail", provider: "github", itemType: "pr", platformHost: "github.com", + repoPath: "acme/widgets", owner: "acme", name: "widgets", number: 42, @@ -269,6 +270,7 @@ describe("router embed-workspace routes", () => { provider: "gitlab", itemType: "issue", platformHost: "gitlab.example.com", + repoPath: "acme/widgets", owner: "acme", name: "widgets", number: 7, @@ -285,23 +287,42 @@ describe("router embed-workspace routes", () => { provider: "github", itemType: "pr", platformHost: "ghe.example.com", + repoPath: "acme/widgets", + owner: "acme", + name: "widgets", + number: 42, + }); + }); + + it("parses legacy provider-explicit detail path without repo_path", () => { + navigate( + "/workspaces/embed/detail/github/pr/github.com/acme/widgets/42?branch=main", + ); + expect(getRoute()).toEqual({ + page: "embed-workspace-detail", + provider: "github", + itemType: "pr", + platformHost: "github.com", + repoPath: "acme/widgets", owner: "acme", name: "widgets", number: 42, + branch: "main", }); }); it("parses /workspaces/embed/detail with branch and tab query", () => { navigate( - "/workspaces/embed/detail/gitlab/issue/git.example.com/org/repo/7" + - "?branch=feature%2Fx&tab=reviews", + "/workspaces/embed/detail/gitlab/issue/git.example.com/7" + + "?repo_path=org%2Fteam%2Frepo&branch=feature%2Fx&tab=reviews", ); expect(getRoute()).toEqual({ page: "embed-workspace-detail", provider: "gitlab", itemType: "issue", platformHost: "git.example.com", - owner: "org", + repoPath: "org/team/repo", + owner: "org/team", name: "repo", number: 7, branch: "feature/x", @@ -311,7 +332,7 @@ describe("router embed-workspace routes", () => { it("ignores unknown tab values on the detail route", () => { navigate( - "/workspaces/embed/detail/github/pr/github.com/o/n/1?tab=garbage", + "/workspaces/embed/detail/github/pr/github.com/1?repo_path=o%2Fn&tab=garbage", ); const route = getRoute(); expect(route).toEqual({ @@ -319,6 +340,7 @@ describe("router embed-workspace routes", () => { provider: "github", itemType: "pr", platformHost: "github.com", + repoPath: "o/n", owner: "o", name: "n", number: 1, @@ -440,7 +462,7 @@ describe("router navigation events", () => { const embedPaths = [ "/workspaces/embed/list", "/workspaces/embed/terminal/ws-1", - "/workspaces/embed/detail/github/pr/github.com/acme/widget/42", + "/workspaces/embed/detail/github/pr/github.com/42?repo_path=acme%2Fwidget", "/workspaces/embed/empty/noWorkspace", "/workspaces/embed/first-run", "/workspaces/embed/project/prj_abc123", diff --git a/frontend/tests/e2e/workspaces.spec.ts b/frontend/tests/e2e/workspaces.spec.ts index c4b3bd58f..1caf2d03a 100644 --- a/frontend/tests/e2e/workspaces.spec.ts +++ b/frontend/tests/e2e/workspaces.spec.ts @@ -165,3 +165,62 @@ test("provider-explicit embed detail route uses provider in detail request", asy await detailRequest; await expect(page.getByText("Provider-explicit GitLab issue")).toBeVisible(); }); + +test("nested repo_path embed detail route loads matching detail content", async ({ page }) => { + const detailRequest = page.waitForRequest( + (request) => + request.method() === "GET" && + new URL(request.url()).pathname === + "/api/v1/host/git.example.com/issues/gitlab/group%2Fsubgroup/project/7", + ); + await page.route( + "**/api/v1/host/git.example.com/issues/gitlab/group%2Fsubgroup/project/7", + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + issue: { + ID: 7, + RepoID: 7, + GitHubID: 7007, + Number: 7, + URL: "https://git.example.com/group/subgroup/project/-/issues/7", + Title: "Nested GitLab issue", + Author: "marius", + State: "open", + Body: "", + CommentCount: 0, + LabelsJSON: "[]", + CreatedAt: "2026-03-28T14:00:00Z", + UpdatedAt: "2026-03-30T14:00:00Z", + LastActivityAt: "2026-03-30T14:00:00Z", + ClosedAt: null, + Starred: false, + }, + repo: { + provider: "gitlab", + platform_host: "git.example.com", + owner: "group/subgroup", + name: "project", + repo_path: "group/subgroup/project", + }, + events: [], + platform_host: "git.example.com", + repo_owner: "group/subgroup", + repo_name: "project", + detail_loaded: true, + detail_fetched_at: "2026-03-30T14:00:00Z", + }), + }); + }, + ); + + await page.goto( + "/workspaces/embed/detail/gitlab/issue/git.example.com/7" + + "?repo_path=group%2Fsubgroup%2Fproject", + ); + + await detailRequest; + await expect(page.getByText("Nested GitLab issue")).toBeVisible(); +}); diff --git a/internal/server/api_test.go b/internal/server/api_test.go index 9f67cb85d..d57b8e1cb 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -10082,6 +10082,67 @@ func TestProviderIssueRouteGeneratedClientEscapesGitLabRepoPath(t *testing.T) { assert.Equal(number, resp.JSON200.Issue.Number) } +func TestProviderIssueRouteHandlesNestedGitLabRepoPathOverHTTP(t *testing.T) { + require := require.New(t) + assert := Assert.New(t) + srv, database := setupTestServer(t) + ctx := t.Context() + now := time.Now().UTC().Truncate(time.Second) + + repoID, err := database.UpsertRepo(ctx, db.RepoIdentity{ + Platform: "gitlab", + PlatformHost: "git.example.com", + Owner: "group/subgroup", + Name: "project", + RepoPath: "group/subgroup/project", + }) + require.NoError(err) + _, err = database.UpsertIssue(ctx, &db.Issue{ + RepoID: repoID, + PlatformID: 7007, + Number: 7, + URL: "https://git.example.com/group/subgroup/project/-/issues/7", + Title: "Nested GitLab issue", + Author: "testuser", + State: "open", + CreatedAt: now, + UpdatedAt: now, + LastActivityAt: now, + }) + require.NoError(err) + + req := httptest.NewRequest( + http.MethodGet, + "/api/v1/host/git.example.com/issues/gitlab/group%2Fsubgroup/project/7", + nil, + ) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + require.Equal(http.StatusOK, rr.Code, rr.Body.String()) + + var body struct { + Issue struct { + Number int64 `json:"number"` + Title string `json:"title"` + } `json:"issue"` + Repo struct { + Provider string `json:"provider"` + PlatformHost string `json:"platform_host"` + Owner string `json:"owner"` + Name string `json:"name"` + RepoPath string `json:"repo_path"` + } `json:"repo"` + } + require.NoError(json.Unmarshal(rr.Body.Bytes(), &body)) + assert.Equal(int64(7), body.Issue.Number) + assert.Equal("Nested GitLab issue", body.Issue.Title) + assert.Equal("gitlab", body.Repo.Provider) + assert.Equal("git.example.com", body.Repo.PlatformHost) + assert.Equal("group/subgroup", body.Repo.Owner) + assert.Equal("project", body.Repo.Name) + assert.Equal("group/subgroup/project", body.Repo.RepoPath) +} + func TestMRListEmptyLinksWhenNone(t *testing.T) { require := require.New(t) srv, database := setupTestServer(t)