feat(github): unify workspace GitHub access (#2084)

This commit is contained in:
Carlos Florêncio
2026-07-31 19:20:49 +01:00
committed by GitHub
parent 1c267cf527
commit aeb7c62e49
27 changed files with 1573 additions and 419 deletions
@@ -1,162 +1,108 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { IconInfoCircle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@kandev/ui/drawer";
import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsSection } from "@/components/settings/settings-section";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { useToast } from "@/components/toast-provider";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import {
fetchGitHubWorkspaceSettings,
updateGitHubWorkspaceSettings,
} from "@/lib/api/domains/github-api";
import type { TaskGitCredentialsState } from "@/hooks/domains/github/use-task-git-credentials";
import type { TaskGitCredentialsMode } from "@/lib/types/github";
import { GitHubAccessHelp } from "./github-access-help";
const deliveryHelp =
"PAT, named GitHub CLI, and GitHub App connections choose Kandev's workspace automation identity. In managed mode Kandev brokers that identity to the task for GitHub HTTPS and gh. Inherit executor credentials leaves task Git and gh to the selected executor. An explicit executor-profile GH_TOKEN or GITHUB_TOKEN takes precedence in managed mode.";
const taskAccessLabels: Record<TaskGitCredentialsMode, string> = {
managed: "Managed workspace credentials",
executor: "Inherit executor Git credentials",
};
function TaskCredentialsHelp() {
const usesDrawer = useTouchDrawer();
const [open, setOpen] = useState(false);
const button = (
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11 cursor-pointer text-muted-foreground sm:h-7 sm:w-7"
aria-label="Explain task Git credentials"
>
<IconInfoCircle className="h-4 w-4" />
</Button>
);
const trigger = <DrawerTrigger asChild>{button}</DrawerTrigger>;
export function GitHubTaskAccessSummary({
mode,
loading,
error,
}: Omit<TaskGitCredentialsState, "save">) {
let value = taskAccessLabels[mode];
if (loading) value = "Loading task access…";
if (error) value = "Task access unavailable";
return (
<Drawer open={open} onOpenChange={setOpen}>
{usesDrawer ? (
trigger
) : (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent className="max-w-[320px] text-xs leading-relaxed">
{deliveryHelp}
</TooltipContent>
</Tooltip>
<div
className="flex items-center gap-1 text-xs text-muted-foreground"
data-testid="github-task-access-summary"
>
<GitHubAccessHelp
label="Explain task Git access"
title="Task Git access"
description="Controls how newly launched tasks authenticate to GitHub for Git HTTPS and gh commands. Managed workspace credentials use Kandev's workspace connection; executor credentials use the selected executor's Git or SSH setup."
/>
<span>
<span className="font-medium text-foreground">Task access: </span>
{value}
</span>
</div>
);
}
export function GitHubTaskAccessForm({
taskAccess,
value,
onChange,
disabled,
}: {
taskAccess: TaskGitCredentialsState;
value: TaskGitCredentialsMode;
onChange: (value: TaskGitCredentialsMode) => void;
disabled?: boolean;
}) {
return (
<section className="space-y-4 border-t pt-5" data-testid="github-task-access-settings">
<div className="space-y-1">
<div className="flex items-center gap-1">
<h3 className="text-sm font-medium">Task Git access</h3>
<GitHubAccessHelp
label="Explain how managed task credentials work"
title="How managed task credentials work"
description="With Managed workspace credentials, Kandev configures agentctl as Git's credential helper inside newly launched task processes. When Git requests credentials for an attached GitHub HTTPS repository, the helper redeems a task- and repository-scoped broker lease. The credential is returned to Git on demand and is not written to the repository or Git config. The gh command uses Kandev's broker-aware shim. Executor inheritance skips both and uses credentials visible in the selected executor. Changes apply to new task launches and the next resume, not already-running processes."
/>
</div>
<p className="text-xs leading-5 text-muted-foreground">
Choose how newly launched tasks authenticate to GitHub. This does not change the workspace
automation connection above.
</p>
</div>
<RadioGroup
value={value}
onValueChange={(nextValue) => onChange(nextValue as TaskGitCredentialsMode)}
disabled={taskAccess.loading || taskAccess.error || disabled}
className="gap-2"
>
<label
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3"
data-testid="github-task-access-option-managed"
>
<RadioGroupItem value="managed" className="mt-0.5" />
<span>
<span className="font-medium">Managed workspace credentials</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
Kandev brokers the workspace PAT, named GitHub CLI account, or App identity to this
task for GitHub HTTPS and gh.
</span>
</span>
</label>
<label
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3"
data-testid="github-task-access-option-executor"
>
<RadioGroupItem value="executor" className="mt-0.5" />
<span>
<span className="font-medium">Inherit executor Git credentials</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
Local and Worktree tasks use host-visible Git or SSH credentials. Docker, SSH, and
cloud tasks use credentials configured in that executor.
</span>
</span>
</label>
</RadioGroup>
<p className="text-xs leading-5 text-muted-foreground">
An executor-profile GH_TOKEN or GITHUB_TOKEN overrides managed workspace credentials for
that task.
</p>
{taskAccess.error && (
<p className="text-sm text-destructive">Unable to load the current task access setting.</p>
)}
<DrawerContent>
<DrawerHeader>
<DrawerTitle>How task Git credentials work</DrawerTitle>
<DrawerDescription>{deliveryHelp}</DrawerDescription>
</DrawerHeader>
</DrawerContent>
</Drawer>
);
}
export function GitHubTaskCredentialsSection({ workspaceId }: { workspaceId: string }) {
const { toast } = useToast();
const [mode, setMode] = useState<TaskGitCredentialsMode>("managed");
const [baseline, setBaseline] = useState<TaskGitCredentialsMode>("managed");
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
void fetchGitHubWorkspaceSettings(workspaceId)
.then((settings) => {
if (!cancelled) {
const next = settings.task_git_credentials_mode ?? "managed";
setMode(next);
setBaseline(next);
}
})
.catch(() => {
if (!cancelled)
toast({ description: "Failed to load task Git credential settings", variant: "error" });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [toast, workspaceId]);
const save = useCallback(async () => {
try {
const updated = await updateGitHubWorkspaceSettings({
workspace_id: workspaceId,
task_git_credentials_mode: mode,
});
const next = updated.task_git_credentials_mode ?? "managed";
setBaseline(next);
setMode((current) => (current === mode ? next : current));
toast({ description: "Task Git credential settings saved", variant: "success" });
} catch {
toast({ description: "Failed to save task Git credential settings", variant: "error" });
throw new Error("Failed to save task Git credential settings");
}
}, [mode, toast, workspaceId]);
const dirty = mode !== baseline;
useSettingsSaveContributor({
id: `github-task-credentials:${workspaceId}`,
revision: mode,
isDirty: dirty,
canSave: !loading,
save,
discard: () => setMode(baseline),
});
return (
<SettingsSection
title="Task Git credentials"
description="Choose how task processes authenticate to GitHub. This does not change Kandev's workspace automation identity."
action={<TaskCredentialsHelp />}
>
<SettingsCard isDirty={dirty}>
<CardContent className="space-y-4 py-4">
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as TaskGitCredentialsMode)}
disabled={loading}
data-testid="github-task-credentials-mode"
>
<label className="flex cursor-pointer items-start gap-3 rounded-md border p-3">
<RadioGroupItem value="managed" className="mt-0.5" />
<span>
<span className="font-medium">Managed workspace credentials</span>
<span className="mt-1 block text-sm text-muted-foreground">
Kandev brokers the workspace PAT, named GitHub CLI account, or App identity to
this task for GitHub HTTPS and gh.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-3 rounded-md border p-3">
<RadioGroupItem value="executor" className="mt-0.5" />
<span>
<span className="font-medium">Inherit executor Git credentials</span>
<span className="mt-1 block text-sm text-muted-foreground">
Local and Worktree tasks use host-visible Git or SSH credentials. Docker, SSH, and
cloud tasks use credentials configured in that executor.
</span>
</span>
</label>
</RadioGroup>
<p className="text-xs text-muted-foreground">
An executor-profile GH_TOKEN or GITHUB_TOKEN overrides managed workspace credentials for
that task.
</p>
</CardContent>
</SettingsCard>
</SettingsSection>
</section>
);
}