Skip to content
Merged
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
2 changes: 1 addition & 1 deletion frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
58 changes: 52 additions & 6 deletions frontend/src/lib/stores/router.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type Route =
provider: string;
itemType: "pr" | "issue";
platformHost: string;
repoPath: string;
owner: string;
name: string;
number: number;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand All @@ -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;
Expand All @@ -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;
Expand Down
36 changes: 29 additions & 7 deletions frontend/src/lib/stores/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -311,14 +332,15 @@ 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({
page: "embed-workspace-detail",
provider: "github",
itemType: "pr",
platformHost: "github.com",
repoPath: "o/n",
owner: "o",
name: "n",
number: 1,
Expand Down Expand Up @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions frontend/tests/e2e/workspaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
61 changes: 61 additions & 0 deletions internal/server/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading