fix: avoid stale task worktree repository launches (#1568)

This commit is contained in:
Carlos Florêncio
2026-07-01 21:25:05 +01:00
committed by GitHub
parent 4b981bc4c6
commit a30dd39566
20 changed files with 1336 additions and 68 deletions
@@ -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}
/>