fix: avoid stale task worktree repository launches (#1568)
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import type { GitHubIssue, GitHubPR } from "@/lib/types/github";
|
||||
import {
|
||||
repositoryId,
|
||||
workspaceId,
|
||||
type Repository,
|
||||
type Workflow,
|
||||
type WorkflowStep,
|
||||
} from "@/lib/types/http";
|
||||
import { QuickTaskLauncher, type LaunchPayload, type TaskPreset } from "./quick-task-launcher";
|
||||
|
||||
const NOW = "2026-07-01T00:00:00Z";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const WORKFLOW_ID = "workflow-1";
|
||||
const PR_HEAD_BRANCH = "feature/adding-a-download-ot-5sl";
|
||||
const TASK_WORKTREE_ROOT = "/root/.kandev/tasks";
|
||||
const TASK_WORKTREE_PATH = "/root/.kandev/tasks/pr-1541-fix-skip-cle_3bm/kdlbs-kandev";
|
||||
const REPO_URL = "https://github.com/kdlbs/kandev/pull/1567";
|
||||
const ISSUE_URL = "https://github.com/kdlbs/kandev/issues/1567";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dialogProps: undefined as { initialValues?: Record<string, unknown> } | undefined,
|
||||
push: vi.fn(),
|
||||
createTaskPR: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task-create-dialog", () => ({
|
||||
TaskCreateDialog: (props: { initialValues?: Record<string, unknown> }) => {
|
||||
mocks.dialogProps = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/routing/client-router", () => ({
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/github-api", () => ({
|
||||
createTaskPR: mocks.createTaskPR,
|
||||
}));
|
||||
|
||||
const preset: TaskPreset = {
|
||||
id: "review",
|
||||
label: "Review",
|
||||
hint: "Review the PR",
|
||||
icon: (() => null) as unknown as Icon,
|
||||
prompt: ({ url }) => `Review ${url}`,
|
||||
};
|
||||
|
||||
const workflow = {
|
||||
id: WORKFLOW_ID,
|
||||
workspace_id: WORKSPACE_ID,
|
||||
name: "Workflow",
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
} as Workflow;
|
||||
|
||||
const step = {
|
||||
id: "step-1",
|
||||
workflow_id: WORKFLOW_ID,
|
||||
name: "Backlog",
|
||||
position: 1,
|
||||
color: "gray",
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
} as WorkflowStep;
|
||||
|
||||
function pr(overrides: Partial<GitHubPR> = {}): GitHubPR {
|
||||
return {
|
||||
number: 1567,
|
||||
title: "feat: add Download option",
|
||||
url: REPO_URL,
|
||||
html_url: REPO_URL,
|
||||
state: "open",
|
||||
head_branch: PR_HEAD_BRANCH,
|
||||
base_branch: "main",
|
||||
author_login: "contributor",
|
||||
repo_owner: "kdlbs",
|
||||
repo_name: "kandev",
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
requested_reviewers: [],
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
merged_at: null,
|
||||
closed_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function issue(overrides: Partial<GitHubIssue> = {}): GitHubIssue {
|
||||
return {
|
||||
number: 1567,
|
||||
title: "add Download option",
|
||||
body: "",
|
||||
url: ISSUE_URL,
|
||||
html_url: ISSUE_URL,
|
||||
state: "open",
|
||||
author_login: "contributor",
|
||||
repo_owner: "kdlbs",
|
||||
repo_name: "kandev",
|
||||
labels: [],
|
||||
assignees: [],
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
closed_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type RepoOverrides = Omit<Partial<Repository>, "id" | "workspace_id"> & { id: string };
|
||||
|
||||
function repo(overrides: RepoOverrides): Repository {
|
||||
const { id, ...rest } = overrides;
|
||||
return {
|
||||
id: repositoryId(id),
|
||||
workspace_id: workspaceId(WORKSPACE_ID),
|
||||
name: "kdlbs/kandev",
|
||||
source_type: "local",
|
||||
local_path: "/work/kandev",
|
||||
provider: "github",
|
||||
provider_repo_id: "",
|
||||
provider_owner: "kdlbs",
|
||||
provider_name: "kandev",
|
||||
default_branch: "main",
|
||||
worktree_branch_prefix: "feature/",
|
||||
pull_before_worktree: true,
|
||||
setup_script: "",
|
||||
cleanup_script: "",
|
||||
dev_script: "",
|
||||
copy_files: "",
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function renderLauncher(repositories: Repository[], prOverrides: Partial<GitHubPR> = {}) {
|
||||
const payload: LaunchPayload = { kind: "pr", pr: pr(prOverrides), preset };
|
||||
render(
|
||||
<QuickTaskLauncher
|
||||
workspaceId={WORKSPACE_ID}
|
||||
workflows={[workflow]}
|
||||
steps={[step]}
|
||||
repositories={repositories}
|
||||
payload={payload}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return mocks.dialogProps?.initialValues;
|
||||
}
|
||||
|
||||
function renderIssueLauncher(
|
||||
repositories: Repository[],
|
||||
issueOverrides: Partial<GitHubIssue> = {},
|
||||
) {
|
||||
const payload: LaunchPayload = { kind: "issue", issue: issue(issueOverrides), preset };
|
||||
render(
|
||||
<QuickTaskLauncher
|
||||
workspaceId={WORKSPACE_ID}
|
||||
workflows={[workflow]}
|
||||
steps={[step]}
|
||||
repositories={repositories}
|
||||
payload={payload}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return mocks.dialogProps?.initialValues;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mocks.dialogProps = undefined;
|
||||
mocks.push.mockClear();
|
||||
mocks.createTaskPR.mockClear();
|
||||
});
|
||||
|
||||
describe("QuickTaskLauncher repository defaults", () => {
|
||||
it("falls back to Remote mode when the only matching repo is a task worktree path", () => {
|
||||
const initialValues = renderLauncher([
|
||||
repo({ id: "task-worktree", local_path: TASK_WORKTREE_PATH }),
|
||||
]);
|
||||
|
||||
expect(initialValues).toMatchObject({
|
||||
githubUrl: REPO_URL,
|
||||
branch: PR_HEAD_BRANCH,
|
||||
checkoutBranch: PR_HEAD_BRANCH,
|
||||
prNumber: 1567,
|
||||
prBaseBranch: "main",
|
||||
});
|
||||
expect(initialValues?.repositoryId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens PR launches in Remote mode even when a provider-backed repo exists", () => {
|
||||
const initialValues = renderLauncher([
|
||||
repo({ id: "task-worktree", local_path: TASK_WORKTREE_PATH }),
|
||||
repo({
|
||||
id: "provider-repo",
|
||||
source_type: "provider",
|
||||
local_path: "",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(initialValues).toMatchObject({
|
||||
githubUrl: REPO_URL,
|
||||
branch: PR_HEAD_BRANCH,
|
||||
checkoutBranch: PR_HEAD_BRANCH,
|
||||
prNumber: 1567,
|
||||
prBaseBranch: "main",
|
||||
});
|
||||
expect(initialValues?.repositoryId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still preselects an ordinary matching local GitHub repo for issues", () => {
|
||||
const initialValues = renderIssueLauncher([
|
||||
repo({ id: "local-repo", local_path: "/work/kandev" }),
|
||||
]);
|
||||
|
||||
expect(initialValues).toMatchObject({
|
||||
repositoryId: "local-repo",
|
||||
});
|
||||
expect(initialValues?.githubUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects task worktree roots without a trailing slash", () => {
|
||||
const initialValues = renderIssueLauncher([
|
||||
repo({ id: "task-worktree-root", local_path: TASK_WORKTREE_ROOT }),
|
||||
]);
|
||||
|
||||
expect(initialValues).toMatchObject({
|
||||
githubUrl: "github.com/kdlbs/kandev",
|
||||
});
|
||||
expect(initialValues?.repositoryId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives a PR URL when the GitHub payload omits URL fields", () => {
|
||||
const initialValues = renderLauncher(
|
||||
[repo({ id: "task-worktree", local_path: TASK_WORKTREE_PATH })],
|
||||
{ html_url: "", url: "" },
|
||||
);
|
||||
|
||||
expect(initialValues).toMatchObject({
|
||||
githubUrl: REPO_URL,
|
||||
branch: PR_HEAD_BRANCH,
|
||||
checkoutBranch: PR_HEAD_BRANCH,
|
||||
prNumber: 1567,
|
||||
prBaseBranch: "main",
|
||||
});
|
||||
expect(initialValues?.repositoryId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -27,20 +27,61 @@ type DialogState = {
|
||||
branch?: string;
|
||||
checkoutBranch?: string;
|
||||
githubUrl?: string;
|
||||
prNumber?: number;
|
||||
prBaseBranch?: string;
|
||||
};
|
||||
|
||||
const LOCAL_SOURCE_TYPE = "local";
|
||||
const REJECT_REPO_MATCH = Number.POSITIVE_INFINITY;
|
||||
|
||||
function matchRepo(repos: Repository[], owner: string, name: string): Repository | undefined {
|
||||
return repos.find(
|
||||
(r) =>
|
||||
(r.provider_owner || "").toLowerCase() === owner.toLowerCase() &&
|
||||
(r.provider_name || "").toLowerCase() === name.toLowerCase(),
|
||||
let best: Repository | undefined;
|
||||
let bestRank = REJECT_REPO_MATCH;
|
||||
for (const repo of repos) {
|
||||
if (!matchesProviderIdentity(repo, owner, name)) continue;
|
||||
const rank = repositoryLaunchRank(repo);
|
||||
if (rank < bestRank) {
|
||||
best = repo;
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
return bestRank === REJECT_REPO_MATCH ? undefined : best;
|
||||
}
|
||||
|
||||
function matchesProviderIdentity(repo: Repository, owner: string, name: string): boolean {
|
||||
return (
|
||||
(repo.provider_owner || "").toLowerCase() === owner.toLowerCase() &&
|
||||
(repo.provider_name || "").toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function repositoryLaunchRank(repo: Repository): number {
|
||||
if (!isGitHubRepository(repo)) return REJECT_REPO_MATCH;
|
||||
if (isTaskWorktreePath(repo.local_path)) return REJECT_REPO_MATCH;
|
||||
if ((repo.source_type || LOCAL_SOURCE_TYPE) !== LOCAL_SOURCE_TYPE) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isGitHubRepository(repo: Repository): boolean {
|
||||
const provider = (repo.provider || "").toLowerCase();
|
||||
return provider === "" || provider === "github";
|
||||
}
|
||||
|
||||
function isTaskWorktreePath(path: string | undefined): boolean {
|
||||
const normalized = (path || "").replaceAll("\\", "/");
|
||||
return normalized.includes("/.kandev/tasks/") || normalized.endsWith("/.kandev/tasks");
|
||||
}
|
||||
|
||||
function emptyToUndefined(value: string | undefined): string | undefined {
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function prURL(pr: GitHubPR): string {
|
||||
return (
|
||||
pr.html_url || pr.url || `https://github.com/${pr.repo_owner}/${pr.repo_name}/pull/${pr.number}`
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-repo tasks have one task_repository row per repo; pick the one that
|
||||
// matches the PR's owner/repo. Preference order:
|
||||
// 1. preferredRepositoryId (the id captured at dialog-time by repo matching)
|
||||
@@ -66,7 +107,7 @@ function pickRepositoryIdForPR(
|
||||
function extractPayload(payload: LaunchPayload) {
|
||||
if (payload.kind === "pr") {
|
||||
return {
|
||||
url: payload.pr.html_url,
|
||||
url: prURL(payload.pr),
|
||||
title: payload.pr.title,
|
||||
owner: payload.pr.repo_owner,
|
||||
name: payload.pr.repo_name,
|
||||
@@ -84,7 +125,6 @@ function extractPayload(payload: LaunchPayload) {
|
||||
|
||||
function buildDialogState(payload: LaunchPayload, repositories: Repository[]): DialogState {
|
||||
const data = extractPayload(payload);
|
||||
const repo = matchRepo(repositories, data.owner, data.name);
|
||||
const description = payload.preset.prompt({ url: data.url, title: data.title });
|
||||
const title = `${payload.preset.label}: ${data.title}`;
|
||||
// For a PR launch we want the dialog to display and check out the PR's head
|
||||
@@ -92,6 +132,18 @@ function buildDialogState(payload: LaunchPayload, repositories: Repository[]): D
|
||||
// auto-resolves to the PR head. Same branch for both: the chip shows it and
|
||||
// the worktree checks it out.
|
||||
const checkoutBranch = payload.kind === "pr" ? data.branch : undefined;
|
||||
if (payload.kind === "pr") {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
githubUrl: data.url,
|
||||
branch: data.branch,
|
||||
checkoutBranch,
|
||||
prNumber: payload.pr.number,
|
||||
prBaseBranch: payload.pr.base_branch,
|
||||
};
|
||||
}
|
||||
const repo = matchRepo(repositories, data.owner, data.name);
|
||||
if (repo) {
|
||||
return {
|
||||
title,
|
||||
@@ -164,7 +216,7 @@ export function QuickTaskLauncher({
|
||||
void createTaskPR({
|
||||
task_id: task.id,
|
||||
repository_id: repositoryId,
|
||||
pr_url: payload.pr.html_url,
|
||||
pr_url: prURL(payload.pr),
|
||||
}).catch(() => {
|
||||
// Silently ignore — the indicator will populate via the poller path
|
||||
// (legacy behavior) if branch matching succeeds.
|
||||
@@ -192,6 +244,8 @@ export function QuickTaskLauncher({
|
||||
branch: dialog.branch,
|
||||
checkoutBranch: dialog.checkoutBranch,
|
||||
githubUrl: dialog.githubUrl,
|
||||
prNumber: dialog.prNumber,
|
||||
prBaseBranch: dialog.prBaseBranch,
|
||||
}}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user