feat(FN-3875): add GitHub tracking auth resolver and forced client auth mod

Implements a tracking auth resolver with forced GitHub client authentication mode, wiring it across the GitHub tracking lifecycle and settings UI. The feature spans six steps: adding the resolver, forced auth mode, routing tracking issue creation through the resolver, and wiring into lifecycle and s

Fusion-Task-Id: FN-3875
This commit is contained in:
Fusion
2026-05-10 05:20:26 -07:00
committed by gsxdsm
parent 752222c438
commit 860d18382d
33 changed files with 1402 additions and 242 deletions

View File

@@ -1,5 +1,7 @@
import type { GlobalSettings, ProjectSettings, Task } from "./types.js";
export const REPO_OVERRIDE_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
export interface RepoSlug {
owner: string;
repo: string;
@@ -32,7 +34,7 @@ export function parseRepoSlug(input: string | undefined | null): RepoSlug | null
}
export function isValidRepoSlug(input: string): boolean {
return parseRepoSlug(input) !== null;
return REPO_OVERRIDE_RE.test(input.trim());
}
export function resolveTaskGithubTracking(

View File

@@ -1374,6 +1374,8 @@ export interface TaskCreateInput {
nodeId?: string;
/** Optional explicit user assignment for this task (used during review handoff) */
assigneeUserId?: string;
/** Per-task GitHub issue tracking overrides for Fusion-created linked issues. */
githubTracking?: Pick<TaskGithubTracking, "enabled" | "repoOverride">;
/** Review level for task execution — controls review rigor: 0=None, 1=Plan Only, 2=Plan and Code, 3=Full */
reviewLevel?: number;
/** Execution mode for task implementation.

View File

@@ -311,6 +311,7 @@ export function createTask(
nodeId,
branch,
baseBranch,
githubTracking,
} = input;
return proxyApi<Task>(withProjectId("/tasks", projectId), {
@@ -341,6 +342,7 @@ export function createTask(
nodeId,
branch,
baseBranch,
githubTracking,
}),
});
}
@@ -367,6 +369,11 @@ export function updateTask(
nodeId?: string | null;
branch?: string | null;
baseBranch?: string | null;
githubTracking?: {
enabled?: boolean;
repoOverride?: string | null;
issue?: null;
} | null;
},
projectId?: string,
): Promise<Task> {

View File

@@ -9,6 +9,7 @@ import { Bot } from "lucide-react";
import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { TaskForm, type PendingImage } from "./TaskForm";
import { REPO_OVERRIDE_RE } from "./githubTracking";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -58,6 +59,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const [githubTrackingEnabled, setGithubTrackingEnabled] = useState(false);
const [githubRepoOverride, setGithubRepoOverride] = useState("");
// Agent assignment state
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@@ -158,6 +161,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
const githubRepoOverrideTrimmed = githubRepoOverride.trim();
const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed);
// Track dirty state
useEffect(() => {
const isDirty =
@@ -174,9 +180,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
priority !== DEFAULT_TASK_PRIORITY ||
nodeId !== undefined ||
branch !== "" ||
baseBranch !== "";
baseBranch !== "" ||
githubTrackingEnabled ||
githubRepoOverrideTrimmed !== "";
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId, branch, baseBranch]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority, nodeId, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
const handleClose = useCallback(async () => {
if (hasDirtyState) {
@@ -209,12 +217,14 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setBranch("");
setBaseBranch("");
setHasDirtyState(false);
setGithubTrackingEnabled(false);
setGithubRepoOverride("");
onClose();
}, [hasDirtyState, onClose, pendingImages, confirm]);
const handleSubmit = useCallback(async () => {
const trimmedDesc = description.trim();
if (!trimmedDesc || isSubmitting) return;
if (!trimmedDesc || isSubmitting || githubRepoOverrideInvalid) return;
setIsSubmitting(true);
try {
@@ -244,6 +254,14 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
nodeId,
branch: branch.trim() === "" ? undefined : branch.trim(),
baseBranch: baseBranch.trim() === "" ? undefined : baseBranch.trim(),
...(githubTrackingEnabled || githubRepoOverrideTrimmed !== ""
? {
githubTracking: {
enabled: githubTrackingEnabled,
...(githubRepoOverrideTrimmed !== "" ? { repoOverride: githubRepoOverrideTrimmed } : {}),
},
}
: {}),
});
// Upload pending images as attachments
@@ -500,6 +518,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
nodeId={nodeId}
onNodeIdChange={setNodeId}
nodeOptions={nodes}
githubTrackingEnabled={githubTrackingEnabled}
onGithubTrackingEnabledChange={setGithubTrackingEnabled}
githubRepoOverride={githubRepoOverride}
onGithubRepoOverrideChange={setGithubRepoOverride}
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}
@@ -514,7 +536,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
<button
className="btn btn-primary btn-sm"
onClick={handleSubmit}
disabled={!description.trim() || isSubmitting}
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid}
>
{isSubmitting ? "Creating..." : "Create Task"}
</button>

View File

@@ -435,6 +435,53 @@
border-top: 1px solid var(--border);
}
.detail-github-tracking-section {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
padding: var(--space-sm) var(--space-md);
}
.detail-github-tracking-grid {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--border);
}
.detail-github-issue-state {
font-weight: 600;
text-transform: lowercase;
}
.detail-github-issue-state--open {
color: var(--color-success);
}
.detail-github-issue-state--closed {
color: var(--text-muted);
}
.detail-github-tracking-controls {
margin-top: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.detail-github-tracking-repo-row {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.detail-github-tracking-repo-row .input {
flex: 1;
}
.detail-github-tracking-error {
color: var(--color-error);
}
/* Agent Log tab: stretch to fill the remaining modal body height
so the log viewer uses all available space above the action bar. */
.detail-section--agent-log {
@@ -1477,6 +1524,11 @@
grid-template-columns: 1fr;
}
.detail-github-tracking-repo-row {
flex-direction: column;
align-items: stretch;
}
.detail-priority-chip {
margin-left: 0;
margin-top: var(--space-xs);

View File

@@ -6,7 +6,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult } from "@fusion/core";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult } from "@fusion/core";
import {
COLUMN_LABELS,
DEFAULT_TASK_PRIORITY,
@@ -17,7 +17,7 @@ import {
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { useConfirm } from "../hooks/useConfirm";
@@ -37,10 +37,12 @@ import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
import { PluginSlot } from "./PluginSlot";
import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus";
import { REPO_OVERRIDE_RE } from "./githubTracking";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
interface ModelSelection {
provider?: string;
@@ -531,6 +533,9 @@ export function TaskDetailContent({
const [showMoveMenu, setShowMoveMenu] = useState(false);
const [showActionsMenu, setShowActionsMenu] = useState(false);
const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false);
const [githubRepoOverrideDraft, setGithubRepoOverrideDraft] = useState(task.githubTracking?.repoOverride ?? "");
const [githubRepoOverrideError, setGithubRepoOverrideError] = useState<string | null>(null);
const [isSavingGithubTracking, setIsSavingGithubTracking] = useState(false);
const moveMenuRef = useRef<HTMLDivElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null);
const actionsMenuRef = useRef<HTMLDivElement>(null);
@@ -557,6 +562,7 @@ export function TaskDetailContent({
// Merged project settings for effective model resolution in Agent Log header
const [settings, setSettings] = useState<Settings | undefined>(undefined);
const [globalSettings, setGlobalSettings] = useState<GlobalSettings | null>(null);
// Workflow results state
const [workflowResults, setWorkflowResults] = useState<WorkflowStepResult[]>([]);
@@ -576,8 +582,10 @@ export function TaskDetailContent({
setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
setSourceIssueExpanded(false);
setGithubRepoOverrideDraft(task.githubTracking?.repoOverride ?? "");
setGithubRepoOverrideError(null);
setIsEditing(false);
}, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode]);
}, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode, task.githubTracking]);
useEffect(() => {
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
@@ -601,6 +609,13 @@ export function TaskDetailContent({
.catch(() => {
// Settings fetch failure is non-blocking; fallback to "Using default"
});
fetchGlobalSettings()
.then((nextGlobalSettings) => {
if (!cancelled) setGlobalSettings(nextGlobalSettings);
})
.catch(() => {
if (!cancelled) setGlobalSettings(null);
});
return () => { cancelled = true; };
}, [projectId]);
@@ -760,6 +775,50 @@ export function TaskDetailContent({
// Check if task can be edited
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isSaving;
const githubTrackingEnabled = task.githubTracking?.enabled === true;
const githubTrackedIssue = task.githubTracking?.issue;
const showGithubTrackingSection = githubTrackingEnabled || Boolean(githubTrackedIssue);
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings ?? null, globalSettings);
const githubRepoOverrideTrimmed = githubRepoOverrideDraft.trim();
const handleToggleGithubTracking = useCallback(async () => {
if (!canEdit || isSavingGithubTracking) return;
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, {
githubTracking: {
enabled: !githubTrackingEnabled,
},
}, projectId);
onTaskUpdated?.(updatedTask);
} catch (err) {
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) setIsSavingGithubTracking(false);
}
}, [addToast, canEdit, githubTrackingEnabled, isSavingGithubTracking, onTaskUpdated, projectId, task.id]);
const handleSaveGithubRepoOverride = useCallback(async () => {
if (!canEdit || isSavingGithubTracking) return;
if (githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed)) {
setGithubRepoOverrideError("Repository override must be in owner/repo format");
return;
}
setGithubRepoOverrideError(null);
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, {
githubTracking: {
repoOverride: githubRepoOverrideTrimmed.length > 0 ? githubRepoOverrideTrimmed : null,
},
}, projectId);
onTaskUpdated?.(updatedTask);
} catch (err) {
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) setIsSavingGithubTracking(false);
}
}, [addToast, canEdit, githubRepoOverrideTrimmed, isSavingGithubTracking, onTaskUpdated, projectId, task.id]);
const enterEditMode = useCallback(() => {
if (!canEdit) return;
@@ -1055,6 +1114,29 @@ export function TaskDetailContent({
const fileInputRef = useRef<HTMLInputElement>(null);
const { nodes } = useNodes();
const { confirm } = useConfirm();
const handleUnlinkGithubIssue = useCallback(async () => {
if (!canEdit || !githubTrackedIssue || isSavingGithubTracking) return;
const confirmed = await confirm({
title: "Unlink GitHub issue?",
body: "This stops Fusion from syncing with the linked GitHub issue. The issue itself will not be modified.",
confirmLabel: "Unlink",
tone: "danger",
});
if (!confirmed) return;
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, { githubTracking: { issue: null } }, projectId);
onTaskUpdated?.(updatedTask);
addToast("GitHub issue unlinked", "success");
} catch (err) {
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) setIsSavingGithubTracking(false);
}
}, [addToast, canEdit, confirm, githubTrackedIssue, isSavingGithubTracking, onTaskUpdated, projectId, task.id]);
const {
entries: agentLogEntries,
loading: agentLogLoading,
@@ -2244,6 +2326,78 @@ export function TaskDetailContent({
)}
</div>
)}
{showGithubTrackingSection && (
<div className="detail-section detail-github-tracking-section">
<div className="detail-source-header">
<div className="detail-source-summary">
<span className="detail-source-label">GitHub tracking</span>
<span className="detail-source-provider-badge" aria-label="GitHub tracking status">
<GitBranch aria-hidden="true" />
<span>{githubTrackedIssue ? "Linked" : "Pending"}</span>
</span>
{!githubTrackedIssue && <span className="detail-source-empty">Not yet created</span>}
</div>
</div>
{githubTrackedIssue && (
<dl className="detail-source-grid detail-github-tracking-grid">
<div>
<dt>Issue</dt>
<dd>
{githubTrackedIssue.url ? (
<a className="detail-source-link" href={githubTrackedIssue.url} target="_blank" rel="noopener noreferrer">
{`${githubTrackedIssue.owner}/${githubTrackedIssue.repo}#${githubTrackedIssue.number}`}
</a>
) : (
<span>{`${githubTrackedIssue.owner}/${githubTrackedIssue.repo}#${githubTrackedIssue.number}`}</span>
)}
</dd>
</div>
<div>
<dt>State</dt>
<dd>
<span className={`detail-github-issue-state ${task.issueInfo?.state === "closed" ? "detail-github-issue-state--closed" : "detail-github-issue-state--open"}`}>
{task.issueInfo?.state ?? "open"}
</span>
</dd>
</div>
</dl>
)}
{canEdit && (
<div className="detail-github-tracking-controls">
<label className="checkbox-label" htmlFor="detail-github-tracking-toggle">
<input
id="detail-github-tracking-toggle"
type="checkbox"
checked={githubTrackingEnabled}
disabled={isSavingGithubTracking}
onChange={() => void handleToggleGithubTracking()}
/>
Enable GitHub tracking
</label>
<div className="detail-github-tracking-repo-row">
<input
className="input"
value={githubRepoOverrideDraft}
onChange={(event) => {
setGithubRepoOverrideDraft(event.target.value);
setGithubRepoOverrideError(null);
}}
placeholder={effectiveGithubRepoDefault || "owner/repo"}
/>
<button className="btn btn-sm" onClick={() => void handleSaveGithubRepoOverride()} disabled={isSavingGithubTracking}>
Save
</button>
</div>
{githubRepoOverrideError && <small className="detail-github-tracking-error">{githubRepoOverrideError}</small>}
{githubTrackedIssue && (
<button className="btn btn-sm touch-target" onClick={() => void handleUnlinkGithubIssue()} disabled={isSavingGithubTracking}>
Unlink GitHub issue
</button>
)}
</div>
)}
</div>
)}
<div className="detail-section detail-agent-section">
<div className="detail-meta-row">
<div className="detail-meta-left">

View File

@@ -1,11 +1,12 @@
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { NodeHealthDot } from "./NodeHealthDot";
import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucide-react";
import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking";
function getNodeStatusLabel(status: NodeInfo["status"]): string {
if (status === "online") return "Online";
@@ -99,6 +100,10 @@ export interface TaskFormProps {
onReviewLevelChange?: (value: number | undefined) => void;
executionMode?: TaskExecutionModeSelection;
onExecutionModeChange?: (value: TaskExecutionModeSelection) => void;
githubTrackingEnabled?: boolean;
onGithubTrackingEnabledChange?: (value: boolean) => void;
githubRepoOverride?: string;
onGithubRepoOverrideChange?: (value: string) => void;
// AI-assisted creation callbacks (create mode only)
onPlanningMode?: (initialPlan: string) => void;
@@ -168,6 +173,10 @@ export function TaskForm({
onReviewLevelChange,
executionMode,
onExecutionModeChange,
githubTrackingEnabled,
onGithubTrackingEnabledChange,
githubRepoOverride,
onGithubRepoOverrideChange,
}: TaskFormProps) {
const hasInitialMoreOptions =
(hideDependencies ? false : dependencies.length > 0) ||
@@ -183,7 +192,9 @@ export function TaskForm({
executionMode === "fast" ||
(branch || "") !== "" ||
(baseBranch || "") !== "" ||
(nodeId || "") !== "";
(nodeId || "") !== "" ||
githubTrackingEnabled === true ||
(githubRepoOverride || "") !== "";
const [showDepDropdown, setShowDepDropdown] = useState(false);
const [showMoreOptions, setShowMoreOptions] = useState(
@@ -195,6 +206,7 @@ export function TaskForm({
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
const [globalSettings, setGlobalSettings] = useState<GlobalSettings | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
@@ -233,10 +245,16 @@ export function TaskForm({
fetchWorkflowSteps(projectId)
.then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled)))
.catch(() => setWorkflowSteps([]));
fetchGlobalSettings()
.then((nextGlobalSettings) => setGlobalSettings(nextGlobalSettings))
.catch(() => setGlobalSettings(null));
}, [isActive, projectId]);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings, globalSettings);
const githubRepoOverrideTrimmed = (githubRepoOverride || "").trim();
const githubRepoOverrideInvalid = githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed);
const hasMoreOptionSelections =
(hideDependencies ? false : dependencies.length > 0) ||
pendingImages.length > 0 ||
@@ -251,7 +269,9 @@ export function TaskForm({
executionMode === "fast" ||
(branch || "") !== "" ||
(baseBranch || "") !== "" ||
(nodeId || "") !== "";
(nodeId || "") !== "" ||
githubTrackingEnabled === true ||
(githubRepoOverride || "") !== "";
// Auto-select preset by size (create mode only)
useEffect(() => {
@@ -268,6 +288,7 @@ export function TaskForm({
// Auto-select defaultOn workflow steps (create mode, once per activation)
const defaultOnAppliedRef = useRef(false);
const githubTrackingDefaultAppliedRef = useRef(false);
useEffect(() => {
if (mode !== "create" || !isActive) return;
if (defaultOnAppliedRef.current) return;
@@ -289,6 +310,22 @@ export function TaskForm({
}
}, [isActive]);
useEffect(() => {
if (mode !== "create" || !isActive) return;
if (!onGithubTrackingEnabledChange) return;
if (githubTrackingDefaultAppliedRef.current) return;
if (!settings) return;
onGithubTrackingEnabledChange(settings.githubTrackingEnabledByDefault ?? false);
githubTrackingDefaultAppliedRef.current = true;
}, [mode, isActive, settings, onGithubTrackingEnabledChange]);
useEffect(() => {
if (!isActive) {
githubTrackingDefaultAppliedRef.current = false;
}
}, [isActive]);
// Auto-expand advanced options when non-default values are present.
useEffect(() => {
if (!autoExpandMoreOptionsOnSelection) {
@@ -962,6 +999,43 @@ export function TaskForm({
</div>
)}
{(onGithubTrackingEnabledChange || onGithubRepoOverrideChange) && (
<div className="form-group" data-testid="task-form-github-tracking">
<label>GitHub Tracking</label>
{onGithubTrackingEnabledChange && (
<label className="checkbox-label" htmlFor="task-github-tracking-enabled">
<input
id="task-github-tracking-enabled"
type="checkbox"
checked={githubTrackingEnabled === true}
onChange={(event) => {
githubTrackingDefaultAppliedRef.current = true;
onGithubTrackingEnabledChange(event.target.checked);
}}
disabled={disabled}
/>
Enable GitHub issue tracking for this task
</label>
)}
{onGithubRepoOverrideChange && (
<>
<label htmlFor="task-github-repo-override" className="model-select-label">Repository (owner/repo)</label>
<input
id="task-github-repo-override"
className="input"
value={githubRepoOverride || ""}
onChange={(event) => onGithubRepoOverrideChange(event.target.value)}
placeholder={effectiveGithubRepoDefault || "owner/repo"}
disabled={disabled}
/>
{githubRepoOverrideInvalid ? (
<div className="form-error">Repository must be in owner/repo format.</div>
) : null}
</>
)}
</div>
)}
{/* Model Selection */}
<div className="form-group">
<label>Model Configuration</label>

View File

@@ -28,6 +28,7 @@ vi.mock("../../api", () => ({
defaultPresetBySize: {},
}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }),
refineText: vi.fn(),
@@ -1068,4 +1069,29 @@ describe("NewTaskModal", () => {
});
});
});
describe("GitHub tracking", () => {
it("seeds tracking toggle from project settings and submits githubTracking payload", async () => {
const { fetchSettings } = await import("../../api");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
githubTrackingEnabledByDefault: true,
});
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with tracking" } });
const toggle = await screen.findByLabelText("Enable GitHub issue tracking for this task");
fireEvent.click(toggle);
fireEvent.change(screen.getByLabelText("Repository (owner/repo)"), { target: { value: "acme/repo" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -2094,4 +2094,162 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("Plugin A Tab")).toBeNull();
});
});
describe("github tracking section", () => {
it("renders linked issue as link when url exists", () => {
render(
<TaskDetailModal
task={makeTask({
githubTracking: {
enabled: true,
issue: {
owner: "runfusion",
repo: "fusion",
number: 123,
url: "https://github.com/runfusion/fusion/issues/123",
createdAt: "2026-01-01T00:00:00Z",
},
},
issueInfo: { url: "https://github.com/runfusion/fusion/issues/123", number: 123, state: "open", title: "Issue" },
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.getByText("GitHub tracking")).toBeTruthy();
expect(screen.getByRole("link", { name: "runfusion/fusion#123" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/123");
});
it("hides section when tracking is disabled and no issue exists", () => {
render(
<TaskDetailModal
task={makeTask({ githubTracking: { enabled: false } })}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.queryByText("GitHub tracking")).toBeNull();
});
it("sends githubTracking enabled toggle payload", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
githubTracking: {
enabled: false,
issue: {
owner: "runfusion",
repo: "fusion",
number: 99,
url: "https://github.com/runfusion/fusion/issues/99",
createdAt: "2026-01-01T00:00:00Z",
},
},
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
fireEvent.click(screen.getByLabelText("Enable GitHub tracking"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } }, undefined);
});
});
it("sends repo override updates and null when cleared", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", githubTracking: { enabled: true, repoOverride: "runfusion/fusion" } })}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
fireEvent.change(screen.getByPlaceholderText("owner/repo"), { target: { value: "runfusion/cli" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { repoOverride: "runfusion/cli" } }, undefined);
});
fireEvent.change(screen.getByPlaceholderText("owner/repo"), { target: { value: " " } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { repoOverride: null } }, undefined);
});
});
it("unlinks issue after confirm and skips on cancel", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
mockConfirm.mockResolvedValueOnce(false);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
githubTracking: {
enabled: true,
issue: {
owner: "runfusion",
repo: "fusion",
number: 200,
url: "https://github.com/runfusion/fusion/issues/200",
createdAt: "2026-01-01T00:00:00Z",
},
},
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Unlink GitHub issue" }));
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalledWith("FN-001", { githubTracking: { issue: null } }, undefined);
});
mockConfirm.mockResolvedValueOnce(true);
fireEvent.click(screen.getByRole("button", { name: "Unlink GitHub issue" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { issue: null } }, undefined);
});
});
});
});

View File

@@ -28,6 +28,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchAgent: vi.fn().mockResolvedValue(null),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),

View File

@@ -26,6 +26,7 @@ vi.mock("../../api", () => ({
defaultPresetBySize: {},
}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
refineText: vi.fn().mockResolvedValue("Refined text"),
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
@@ -1495,4 +1496,57 @@ describe("TaskForm focus behavior (FN-1459)", () => {
expect(screen.getByTestId("task-form-more-options-toggle")).toBeInTheDocument();
});
});
describe("GitHub tracking controls", () => {
it("seeds tracking toggle from project settings default", async () => {
const { fetchSettings } = await import("../../api");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
githubTrackingEnabledByDefault: true,
});
const onGithubTrackingEnabledChange = vi.fn();
renderTaskForm({
githubTrackingEnabled: false,
onGithubTrackingEnabledChange,
});
await waitFor(() => {
expect(onGithubTrackingEnabledChange).toHaveBeenCalledWith(true);
});
});
it("renders tracking controls and propagates changes", async () => {
const onGithubTrackingEnabledChange = vi.fn();
const onGithubRepoOverrideChange = vi.fn();
renderTaskForm({
githubTrackingEnabled: false,
onGithubTrackingEnabledChange,
githubRepoOverride: "",
onGithubRepoOverrideChange,
});
const toggle = await screen.findByLabelText("Enable GitHub issue tracking for this task");
fireEvent.click(toggle);
expect(onGithubTrackingEnabledChange).toHaveBeenCalledWith(true);
const input = screen.getByLabelText("Repository (owner/repo)");
fireEvent.change(input, { target: { value: "owner/repo" } });
expect(onGithubRepoOverrideChange).toHaveBeenCalledWith("owner/repo");
});
it("shows validation error for invalid repo override", () => {
renderTaskForm({
githubTrackingEnabled: true,
onGithubTrackingEnabledChange: vi.fn(),
githubRepoOverride: "invalid repo",
onGithubRepoOverrideChange: vi.fn(),
});
expect(screen.getByText("Repository must be in owner/repo format.")).toBeInTheDocument();
});
});
});

View File

@@ -1,4 +1,6 @@
import { REPO_OVERRIDE_RE, type GlobalSettings, type ProjectSettings } from "@fusion/core";
import type { GlobalSettings, ProjectSettings } from "@fusion/core";
export const REPO_OVERRIDE_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
function normalizeRepoValue(value: string | null | undefined): string {
const trimmed = value?.trim() ?? "";

View File

@@ -0,0 +1,108 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
};
});
import { isGhAuthenticated, isGhAvailable } from "@fusion/core";
import { resolveGithubTrackingAuth } from "../github-auth.js";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
describe("resolveGithubTrackingAuth", () => {
beforeEach(() => {
mockIsGhAvailable.mockReset();
mockIsGhAuthenticated.mockReset();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
});
it("uses project token in token mode", () => {
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "token", githubAuthToken: "proj-token" },
globalSettings: {},
env: { GITHUB_TOKEN: "env-token" },
});
expect(result).toEqual({ ok: true, auth: { mode: "token", token: "proj-token" } });
});
it("falls back to env token in token mode", () => {
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "token", githubAuthToken: " " },
globalSettings: {},
env: { GITHUB_TOKEN: "env-token" },
});
expect(result).toEqual({ ok: true, auth: { mode: "token", token: "env-token" } });
});
it("returns token_missing when token mode has no token", () => {
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "token", githubAuthToken: "" },
globalSettings: {},
env: {},
});
expect(result).toMatchObject({ ok: false, requestedMode: "token", reason: "token_missing" });
expect(mockIsGhAvailable).not.toHaveBeenCalled();
});
it("resolves gh-cli mode when gh is available and authenticated", () => {
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "gh-cli" },
globalSettings: {},
});
expect(result).toEqual({ ok: true, auth: { mode: "gh-cli" } });
});
it("returns gh_not_installed when gh-cli mode has no gh", () => {
mockIsGhAvailable.mockReturnValue(false);
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "gh-cli" },
globalSettings: {},
});
expect(result).toMatchObject({ ok: false, requestedMode: "gh-cli", reason: "gh_not_installed" });
});
it("returns gh_not_authenticated when gh-cli mode is unauthenticated", () => {
mockIsGhAuthenticated.mockReturnValue(false);
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "gh-cli" },
globalSettings: {},
});
expect(result).toMatchObject({ ok: false, requestedMode: "gh-cli", reason: "gh_not_authenticated" });
});
it("defaults to gh-cli mode when githubAuthMode is undefined", () => {
const result = resolveGithubTrackingAuth({
projectSettings: {},
globalSettings: {},
});
expect(result).toEqual({ ok: true, auth: { mode: "gh-cli" } });
});
it("returns invalid_mode for unsupported mode values", () => {
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "weird" as "gh-cli" },
globalSettings: {},
});
expect(result).toMatchObject({ ok: false, requestedMode: "gh-cli", reason: "invalid_mode" });
});
it("does not cross-fallback from token mode to gh-cli", () => {
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "token" },
globalSettings: {},
env: {},
});
expect(result).toMatchObject({ ok: false, requestedMode: "token", reason: "token_missing" });
expect(mockIsGhAvailable).not.toHaveBeenCalled();
expect(mockIsGhAuthenticated).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
runGh: vi.fn(),
runGhJsonAsync: vi.fn(),
getGhErrorMessage: vi.fn((error) => error instanceof Error ? error.message : String(error)),
};
});
import { getGhErrorMessage, isGhAuthenticated, isGhAvailable, runGh, runGhJsonAsync } from "@fusion/core";
import { GitHubClient } from "../github.js";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const mockRunGh = vi.mocked(runGh);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
const mockGetGhErrorMessage = vi.mocked(getGhErrorMessage);
describe("GitHubClient forced mode", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
mockGetGhErrorMessage.mockImplementation((error: unknown) => error instanceof Error ? error.message : String(error));
});
it("forced token mode uses only REST path", async () => {
const fetchSpy = vi.spyOn(global, "fetch" as never).mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ number: 1, html_url: "https://github.com/o/r/issues/1", created_at: "2026-01-01T00:00:00.000Z" }),
} as never);
mockRunGhJsonAsync.mockRejectedValue(new Error("gh should not run"));
const client = new GitHubClient({ token: "token-123", forceMode: "token" });
await client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
expect(fetchSpy).toHaveBeenCalled();
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
});
it("forced token mode without token throws before network", async () => {
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
throw new Error("fetch should not run");
});
const client = new GitHubClient({ forceMode: "token" });
await expect(client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("forced to token mode");
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
});
it("forced gh-cli mode uses only gh path", async () => {
mockRunGhJsonAsync.mockResolvedValue({ url: "https://github.com/o/r/issues/2", number: 2, createdAt: "2026-01-02T00:00:00.000Z" } as never);
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
throw new Error("fetch should not run");
});
const client = new GitHubClient({ forceMode: "gh-cli" });
await client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
expect(mockRunGhJsonAsync).toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("forced gh-cli mode without gh throws before network", async () => {
mockIsGhAvailable.mockReturnValue(false);
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
throw new Error("fetch should not run");
});
const client = new GitHubClient({ forceMode: "gh-cli" });
await expect(client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("gh CLI is not available");
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
});
it("legacy constructor keeps opportunistic fallback semantics", async () => {
mockRunGh.mockImplementation(() => {
throw new Error("gh failed");
});
const fetchSpy = vi.spyOn(global, "fetch" as never).mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ number: 3, html_url: "https://github.com/o/r/pull/3", title: "t", state: "open", head: { ref: "head" }, base: { ref: "main" }, comments: 0 }),
} as never);
const client = new GitHubClient("token-legacy");
await client.createPr({ owner: "o", repo: "r", title: "t", head: "head", base: "main" });
expect(mockRunGh).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("requireToken throws when token missing", () => {
const client = new GitHubClient({ forceMode: "token" });
expect(() => (client as any).requireToken()).toThrow("forced to token mode");
});
it("requireGh throws when gh unavailable or unauthenticated", () => {
const client = new GitHubClient({ forceMode: "gh-cli" });
mockIsGhAvailable.mockReturnValue(false);
expect(() => (client as any).requireGh()).toThrow("gh CLI is not available");
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(false);
expect(() => (client as any).requireGh()).toThrow("gh CLI is not authenticated");
});
});

View File

@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { maybeCreateTrackingIssue } from "../github-tracking.js";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
runGhJsonAsync: vi.fn(),
};
});
import { isGhAuthenticated, isGhAvailable, runGhJsonAsync } from "@fusion/core";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
function task(): Task {
return {
id: "FN-7",
title: "Track me",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
githubTracking: { enabled: true },
} as Task;
}
describe("tracking auth mode integration", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
mockRunGhJsonAsync.mockResolvedValue({ url: "https://github.com/o/r/issues/5", number: 5, createdAt: "2026-01-01T00:00:00.000Z" } as any);
});
it("token mode uses REST and not gh", async () => {
const fetchSpy = vi.spyOn(global, "fetch" as never).mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ number: 5, html_url: "https://github.com/o/r/issues/5", created_at: "2026-01-01T00:00:00.000Z" }),
} as never);
await maybeCreateTrackingIssue(task(), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
projectSettings: { githubAuthMode: "token", githubAuthToken: "token" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
});
expect(fetchSpy).toHaveBeenCalled();
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
});
it("token mode missing token returns auth_token_missing", async () => {
const recordActivity = vi.fn();
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
throw new Error("fetch should not run");
});
const result = await maybeCreateTrackingIssue(task(), {
taskStore: { recordActivity } as any,
projectSettings: { githubAuthMode: "token", githubAuthToken: "" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
});
expect(result).toEqual({ created: false, reason: "auth_token_missing" });
expect(recordActivity).toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("gh-cli mode uses gh and not REST", async () => {
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
throw new Error("fetch should not run");
});
await maybeCreateTrackingIssue(task(), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
projectSettings: { githubAuthMode: "gh-cli" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
});
expect(mockRunGhJsonAsync).toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("gh unavailable returns auth_gh_not_installed", async () => {
mockIsGhAvailable.mockReturnValue(false);
const result = await maybeCreateTrackingIssue(task(), {
taskStore: { recordActivity: vi.fn() } as any,
projectSettings: { githubAuthMode: "gh-cli" } as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
});
expect(result).toEqual({ created: false, reason: "auth_gh_not_installed" });
});
it("default mode uses gh-cli", async () => {
await maybeCreateTrackingIssue(task(), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
projectSettings: {} as any,
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
});
expect(mockRunGhJsonAsync).toHaveBeenCalled();
});
});

View File

@@ -10,18 +10,30 @@ const { mockCommentOnIssue } = vi.hoisted(() => ({
mockCommentOnIssue: vi.fn(),
}));
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
mockResolveGithubTrackingAuth: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
commentOnIssue: (...args: unknown[]) => mockCommentOnIssue(...args),
})),
}));
vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
}));
class MockStore extends EventEmitter {
logEntry: Mock;
getSettings: Mock;
getGlobalSettingsStore: Mock;
constructor() {
super();
this.logEntry = vi.fn().mockResolvedValue(undefined);
this.getSettings = vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" });
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
}
}
@@ -86,18 +98,11 @@ describe("formatTrackingComment", () => {
describe("GitHubTrackingCommentService", () => {
let store: MockStore;
let service: GitHubTrackingCommentService;
let tokenValue: string;
let tokenCalls: number;
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore();
tokenValue = "ghp_test";
tokenCalls = 0;
service = new GitHubTrackingCommentService(store as unknown as TaskStore, () => {
tokenCalls += 1;
return tokenValue;
});
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
service = new GitHubTrackingCommentService(store as unknown as TaskStore);
});
it("start/stop are idempotent", async () => {
@@ -252,17 +257,14 @@ describe("GitHubTrackingCommentService", () => {
expect(mockCommentOnIssue).not.toHaveBeenCalled();
});
it("invokes token thunk for each call", async () => {
it("resolves auth for each call", async () => {
service.start();
tokenValue = "ghp_1";
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
tokenValue = "ghp_2";
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(2);
expect(tokenCalls).toBe(2);
expect(mockResolveGithubTrackingAuth).toHaveBeenCalledTimes(2);
});
});

View File

@@ -7,18 +7,30 @@ const { mockSetIssueState } = vi.hoisted(() => ({
mockSetIssueState: vi.fn(),
}));
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
mockResolveGithubTrackingAuth: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
})),
}));
vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
}));
class MockStore extends EventEmitter {
logEntry: Mock;
getSettings: Mock;
getGlobalSettingsStore: Mock;
constructor() {
super();
this.logEntry = vi.fn().mockResolvedValue(undefined);
this.getSettings = vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" });
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
}
}
@@ -74,18 +86,11 @@ describe("decideIssueAction", () => {
describe("GitHubTrackingStateService", () => {
let store: MockStore;
let service: GitHubTrackingStateService;
let tokenValue: string;
let tokenCalls: number;
beforeEach(() => {
vi.clearAllMocks();
store = new MockStore();
tokenValue = "ghp_test";
tokenCalls = 0;
service = new GitHubTrackingStateService(store as unknown as TaskStore, () => {
tokenCalls += 1;
return tokenValue;
});
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
service = new GitHubTrackingStateService(store as unknown as TaskStore);
});
it("start/stop are idempotent", async () => {
@@ -233,18 +238,15 @@ describe("GitHubTrackingStateService", () => {
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to reopen GitHub tracking issue", "reopen failed");
});
it("invokes token thunk per call", async () => {
it("resolves auth per call", async () => {
service.start();
tokenValue = "ghp_1";
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
tokenValue = "ghp_2";
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
await flushAsync();
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
expect(tokenCalls).toBe(2);
expect(mockResolveGithubTrackingAuth).toHaveBeenCalledTimes(2);
});
it("emits close then reopen in order", async () => {

View File

@@ -1,5 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
const createIssueMock = vi.fn();
const resolveAuthMock = vi.fn();
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
createIssue: createIssueMock,
})),
}));
vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => resolveAuthMock(...args),
}));
import {
formatTrackingIssueBody,
formatTrackingIssueTitle,
@@ -26,20 +40,11 @@ describe("formatTrackingIssueTitle", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: "Hello" })).toBe("[FN-1] Hello");
});
it("falls back for blank title", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: " \n\t " })).toBe("[FN-1] Untitled task");
});
it("collapses multiline whitespace", () => {
expect(formatTrackingIssueTitle({ id: "FN-1", title: "Hello\n\tWorld" })).toBe("[FN-1] Hello World");
});
it("truncates very long titles while preserving id prefix", () => {
const longTitle = "x".repeat(400);
const formatted = formatTrackingIssueTitle({ id: "FN-123", title: longTitle });
expect(formatted.startsWith("[FN-123] ")).toBe(true);
expect(formatted.length).toBeLessThanOrEqual(240);
expect(formatted.endsWith("…")).toBe(true);
});
});
@@ -52,172 +57,83 @@ describe("formatTrackingIssueBody", () => {
summary: "Summary paragraph",
})).toBe("Fusion task: FN-X\n\nPrimary paragraph");
});
it("uses prompt when description is empty", () => {
expect(formatTrackingIssueBody({ id: "FN-X", description: "", prompt: "Prompt paragraph", summary: "Summary" }))
.toBe("Fusion task: FN-X\n\nPrompt paragraph");
});
it("uses summary when description and prompt are unavailable", () => {
expect(formatTrackingIssueBody({ id: "FN-X", summary: "Summary paragraph" }))
.toBe("Fusion task: FN-X\n\nSummary paragraph");
});
it("falls back when prompt is undefined and sources are empty", () => {
expect(formatTrackingIssueBody({ id: "FN-X", description: " ", summary: " " }))
.toBe("Fusion task: FN-X\n\nNo summary available.");
});
it("strips markdown noise including headings, bullets, and code fences", () => {
const body = formatTrackingIssueBody({
id: "FN-X",
description: "# Heading\n- bullet\n1. numbered\n```ts\nconst x = 1;\n```\nfinal",
});
expect(body).toBe("Fusion task: FN-X\n\nHeading bullet numbered const x = 1; final");
});
it("truncates summary to 500 characters with ellipsis", () => {
const body = formatTrackingIssueBody({ id: "FN-X", description: "a".repeat(600) });
const summary = body.replace("Fusion task: FN-X\n\n", "");
expect(summary.length).toBe(500);
expect(summary.endsWith("…")).toBe(true);
});
it("removes fusion-style localhost task urls", () => {
const body = formatTrackingIssueBody({
id: "FN-1",
description: "See http://localhost:4040/tasks/FN-1 and continue",
});
expect(body).not.toContain("localhost");
expect(body).not.toMatch(/https?:\/\/[^\s]*\/tasks\/FN-/);
});
it("always starts with fusion task reference", () => {
expect(formatTrackingIssueBody({ id: "FN-99", description: "hello" }).startsWith("Fusion task: FN-99\n\n")).toBe(true);
});
});
describe("maybeCreateTrackingIssue", () => {
it("returns tracking_disabled when not enabled", async () => {
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: false } }), {
taskStore: {} as any,
githubClient: {} as any,
projectSettings: {},
globalSettings: {},
});
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
});
it("returns issue_already_linked when issue already exists", async () => {
const result = await maybeCreateTrackingIssue(buildTask({
githubTracking: {
enabled: true,
issue: { owner: "o", repo: "r", number: 1, url: "https://github.com/o/r/issues/1", createdAt: "2026-01-01T00:00:00.000Z" },
},
}), {
taskStore: {} as any,
githubClient: {} as any,
projectSettings: {},
globalSettings: {},
});
expect(result).toEqual({ created: false, reason: "issue_already_linked" });
});
it("returns github_import_source for imported tasks", async () => {
const result = await maybeCreateTrackingIssue(buildTask({
githubTracking: { enabled: true },
sourceType: "github_import",
}), {
taskStore: {} as any,
githubClient: {} as any,
projectSettings: {},
globalSettings: {},
});
expect(result).toEqual({ created: false, reason: "github_import_source" });
});
it("prefers task repo override over project/global defaults", async () => {
const createIssue = vi.fn().mockResolvedValue({
owner: "task-owner",
repo: "task-repo",
number: 11,
htmlUrl: "https://github.com/task-owner/task-repo/issues/11",
createdAt: "2026-01-01T00:00:00.000Z",
});
await maybeCreateTrackingIssue(buildTask({
title: "Test",
githubTracking: { enabled: true, repoOverride: "task-owner/task-repo" },
}), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
githubClient: { createIssue } as any,
projectSettings: { githubTrackingDefaultRepo: "project-owner/project-repo" } as any,
globalSettings: { githubTrackingDefaultRepo: "global-owner/global-repo" } as any,
});
expect(createIssue).toHaveBeenCalledWith(expect.objectContaining({ owner: "task-owner", repo: "task-repo" }));
});
it("creates issue, links metadata, and records activity", async () => {
const createIssue = vi.fn().mockResolvedValue({
beforeEach(() => {
vi.clearAllMocks();
resolveAuthMock.mockReturnValue({ ok: true, auth: { mode: "token", token: "tok" } });
createIssueMock.mockResolvedValue({
owner: "o",
repo: "r",
number: 12,
htmlUrl: "https://github.com/o/r/issues/12",
createdAt: "2026-01-01T00:00:00.000Z",
});
});
it("returns tracking_disabled when not enabled", async () => {
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: false } }), {
taskStore: {} as any,
projectSettings: {},
globalSettings: {},
});
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
});
it("returns no_repo_configured and records activity", async () => {
const recordActivity = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
taskStore: { recordActivity } as any,
projectSettings: {},
globalSettings: {},
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(result).toEqual({ created: false, reason: "no_repo_configured" });
expect(recordActivity).toHaveBeenCalledTimes(1);
expect(createIssueMock).not.toHaveBeenCalled();
});
it("creates issue, links metadata, and records activity", async () => {
const linkGithubIssue = vi.fn();
const recordActivity = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ title: "Test", description: "Short body", githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue, recordActivity } as any,
githubClient: { createIssue } as any,
projectSettings: {},
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
logger: console,
});
expect(result.created).toBe(true);
expect(createIssue).toHaveBeenCalledTimes(1);
expect(createIssue).toHaveBeenCalledWith(expect.objectContaining({
title: "[FN-1] Test",
body: expect.stringMatching(/^Fusion task: FN-1\n\n/),
}));
const calledBody = createIssue.mock.calls[0][0]?.body as string;
expect(calledBody.length).toBeLessThanOrEqual("Fusion task: FN-1\n\n".length + 500);
expect(createIssueMock).toHaveBeenCalledTimes(1);
expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ owner: "o", repo: "r", number: 12 }));
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ type: "github-issue-created", repo: "o/r", number: 12 }),
}));
});
it("returns no_repo_configured and records activity", async () => {
it("returns auth reason when resolver fails", async () => {
resolveAuthMock.mockReturnValue({
ok: false,
requestedMode: "token",
reason: "token_missing",
message: "missing token",
});
const recordActivity = vi.fn();
const warn = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
taskStore: { recordActivity } as any,
githubClient: {} as any,
projectSettings: {},
globalSettings: {},
logger: { warn, info: vi.fn() },
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(result).toEqual({ created: false, reason: "no_repo_configured" });
expect(recordActivity).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalled();
});
it("swallows github errors", async () => {
const warn = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
taskStore: { recordActivity: vi.fn() } as any,
githubClient: { createIssue: vi.fn().mockRejectedValue(new Error("boom")) } as any,
projectSettings: { githubTrackingDefaultRepo: "o/r" } as any,
globalSettings: {},
logger: { warn, info: vi.fn() },
});
expect(result).toEqual({ created: false, reason: "github_error" });
expect(warn).toHaveBeenCalled();
expect(result).toEqual({ created: false, reason: "auth_token_missing" });
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ type: "github-issue-skipped", reason: "token_missing" }),
}));
expect(createIssueMock).not.toHaveBeenCalled();
});
});

View File

@@ -337,6 +337,8 @@ describe("GET /settings", () => {
expect(res.status).toBe(200);
expect(res.body.prAuthAvailable).toBe(true);
expect(res.body.trackingAuthAvailable).toBe(true);
expect(res.body.trackingAuthReason).toBeNull();
expect(res.body.githubTokenConfigured).toBeUndefined();
});
@@ -361,6 +363,8 @@ describe("GET /settings", () => {
expect(res.status).toBe(200);
expect(res.body.prAuthAvailable).toBe(false);
expect(res.body.trackingAuthAvailable).toBe(false);
expect(res.body.trackingAuthReason).toBe("gh_not_installed");
expect(res.body.githubTokenConfigured).toBeUndefined();
});
@@ -551,7 +555,13 @@ describe("PUT /settings", () => {
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ maxConcurrent: 4, githubTokenConfigured: true, prAuthAvailable: true }),
JSON.stringify({
maxConcurrent: 4,
githubTokenConfigured: true,
prAuthAvailable: true,
trackingAuthAvailable: true,
trackingAuthReason: null,
}),
{ "Content-Type": "application/json" },
);
@@ -568,7 +578,13 @@ describe("PUT /settings", () => {
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ maxWorktrees: 10, githubTokenConfigured: true, prAuthAvailable: true }),
JSON.stringify({
maxWorktrees: 10,
githubTokenConfigured: true,
prAuthAvailable: true,
trackingAuthAvailable: false,
trackingAuthReason: "token_missing",
}),
{ "Content-Type": "application/json" },
);

View File

@@ -1656,6 +1656,42 @@ describe("PATCH /tasks/:id", () => {
expect(res.body.error).toContain("sourceIssue.externalIssueId");
});
it("forwards githubTracking updates including null issue unlink", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
githubTracking: {
enabled: true,
repoOverride: "runfusion/fusion",
issue: null,
},
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
githubTracking: {
enabled: true,
repoOverride: "runfusion/fusion",
issue: null,
},
});
});
it("returns 400 for invalid githubTracking repo override format", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
githubTracking: {
repoOverride: "invalid repo",
},
}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("owner/repo");
});
it("does not clear model or assignee fields when they are omitted", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });

View File

@@ -0,0 +1,71 @@
import { isGhAuthenticated, isGhAvailable, type GlobalSettings, type ProjectSettings } from "@fusion/core";
// FN-3868 field names are authoritative; if ProjectSettings.githubAuthMode / githubAuthToken were renamed during FN-3868 review, update the imports/types here to match.
export type GithubTrackingAuth =
| { mode: "token"; token: string }
| { mode: "gh-cli" };
export type GithubTrackingAuthResolution =
| { ok: true; auth: GithubTrackingAuth }
| {
ok: false;
requestedMode: "token" | "gh-cli";
reason: "token_missing" | "gh_not_installed" | "gh_not_authenticated" | "invalid_mode";
message: string;
};
export interface ResolveGithubTrackingAuthDeps {
projectSettings: Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
globalSettings: Pick<GlobalSettings, never>;
env?: NodeJS.ProcessEnv;
}
export function resolveGithubTrackingAuth(
deps: ResolveGithubTrackingAuthDeps,
): GithubTrackingAuthResolution {
const requestedMode = deps.projectSettings.githubAuthMode ?? "gh-cli";
const env = deps.env ?? process.env;
if (requestedMode === "token") {
const token = deps.projectSettings.githubAuthToken?.trim() || env.GITHUB_TOKEN?.trim() || "";
if (!token) {
return {
ok: false,
requestedMode: "token",
reason: "token_missing",
message: "GitHub tracking auth mode is token, but githubAuthToken and GITHUB_TOKEN are both empty.",
};
}
return { ok: true, auth: { mode: "token", token } };
}
if (requestedMode === "gh-cli") {
if (!isGhAvailable()) {
return {
ok: false,
requestedMode: "gh-cli",
reason: "gh_not_installed",
message: "GitHub tracking auth mode is gh-cli, but the gh CLI is not installed or not on PATH.",
};
}
if (!isGhAuthenticated()) {
return {
ok: false,
requestedMode: "gh-cli",
reason: "gh_not_authenticated",
message: "GitHub tracking auth mode is gh-cli, but gh is not authenticated. Run `gh auth login`.",
};
}
return { ok: true, auth: { mode: "gh-cli" } };
}
return {
ok: false,
requestedMode: "gh-cli",
reason: "invalid_mode",
message: `Invalid githubAuthMode: ${String(requestedMode)}. Expected "gh-cli" or "token".`,
};
}

View File

@@ -1,5 +1,6 @@
import type { Task, TaskStore } from "@fusion/core";
import type { GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/core";
import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
const COMMENT_MAX_LENGTH = 500;
@@ -34,15 +35,13 @@ export function formatTrackingComment(
export class GitHubTrackingCommentService {
private readonly store: TaskStore;
private readonly getGitHubToken: () => string | undefined;
private readonly onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(event);
};
private started = false;
constructor(store: TaskStore, getGitHubToken?: () => string | undefined) {
constructor(store: TaskStore) {
this.store = store;
this.getGitHubToken = getGitHubToken ?? (() => process.env.GITHUB_TOKEN);
}
start(): void {
@@ -88,7 +87,17 @@ export class GitHubTrackingCommentService {
const body = formatTrackingComment(event.task, event.to);
try {
const client = new GitHubClient(this.getGitHubToken());
const projectSettings = await this.store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await this.store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
await this.store.logEntry(event.task.id, "Skipped GitHub tracking comment", resolution.message);
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
await client.commentOnIssue(owner, repo, number, body);
await this.store.logEntry(
event.task.id,

View File

@@ -1,5 +1,6 @@
import type { TaskStore } from "@fusion/core";
import type { GlobalSettings, ProjectSettings, TaskStore } from "@fusion/core";
import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived";
@@ -39,15 +40,13 @@ export function decideIssueAction(
export class GitHubTrackingStateService {
private readonly store: TaskStore;
private readonly getGitHubToken: () => string | undefined;
private readonly onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(event);
};
private started = false;
constructor(store: TaskStore, getGitHubToken?: () => string | undefined) {
constructor(store: TaskStore) {
this.store = store;
this.getGitHubToken = getGitHubToken ?? (() => process.env.GITHUB_TOKEN);
}
start(): void {
@@ -87,9 +86,19 @@ export class GitHubTrackingStateService {
return;
}
const client = new GitHubClient(this.getGitHubToken());
try {
const projectSettings = await this.store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await this.store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
await this.store.logEntry(event.task.id, "Skipped GitHub tracking issue state update", resolution.message);
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
await client.setIssueState(
owner,
repo,

View File

@@ -1,6 +1,7 @@
import type { GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/core";
import type { CreatedIssue } from "./github.js";
import type { GitHubClient } from "./github.js";
import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
const TRACKING_ISSUE_TITLE_LIMIT = 240;
const TRACKING_ISSUE_BODY_SUMMARY_LIMIT = 500;
@@ -67,12 +68,22 @@ export function formatTrackingIssueBody(task: {
export interface MaybeCreateTrackingIssueDeps {
taskStore: TaskStore;
githubClient: GitHubClient;
projectSettings: ProjectSettings;
globalSettings: GlobalSettings;
logger?: Pick<Console, "warn" | "info">;
}
export type MaybeCreateTrackingIssueReason =
| "tracking_disabled"
| "issue_already_linked"
| "github_import_source"
| "no_repo_configured"
| "github_error"
| "auth_token_missing"
| "auth_gh_not_installed"
| "auth_gh_not_authenticated"
| "auth_invalid_mode";
function parseRepo(value: string | undefined): { owner: string; repo: string } | null {
if (!value) return null;
const trimmed = value.trim();
@@ -84,7 +95,7 @@ function parseRepo(value: string | undefined): { owner: string; repo: string } |
export async function maybeCreateTrackingIssue(
task: Task,
deps: MaybeCreateTrackingIssueDeps,
): Promise<{ created: false; reason: string } | { created: true; issue: CreatedIssue }> {
): Promise<{ created: false; reason: MaybeCreateTrackingIssueReason } | { created: true; issue: CreatedIssue }> {
const tracking = task.githubTracking;
if (tracking?.enabled !== true) {
return { created: false, reason: "tracking_disabled" };
@@ -115,11 +126,37 @@ export async function maybeCreateTrackingIssue(
return { created: false, reason: "no_repo_configured" };
}
const resolution = resolveGithubTrackingAuth({
projectSettings: deps.projectSettings,
globalSettings: {},
});
if (!resolution.ok) {
deps.logger?.warn?.(`[github-tracking] ${task.id}: auth unavailable (${resolution.reason}): ${resolution.message}`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: task.title,
details: `GitHub tracking issue not created: ${resolution.message}`,
metadata: {
type: "github-issue-skipped",
reason: resolution.reason,
message: resolution.message,
},
});
return { created: false, reason: `auth_${resolution.reason}` };
}
const githubClient = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
const title = formatTrackingIssueTitle(task);
const body = formatTrackingIssueBody(task);
try {
const issue = await deps.githubClient.createIssue({ owner: repo.owner, repo: repo.repo, title, body });
const issue = await githubClient.createIssue({ owner: repo.owner, repo: repo.repo, title, body });
await deps.taskStore.linkGithubIssue(task.id, {
owner: repo.owner,

View File

@@ -370,23 +370,58 @@ export function isPrMergeReady(input: {
};
}
export interface GitHubClientOptions {
token?: string;
/**
* When set, every dual-path method on this client uses ONLY the named transport.
* "token" requires a non-empty `token`; "gh-cli" ignores `token` entirely.
* When undefined, the legacy opportunistic behavior is preserved.
*/
forceMode?: "token" | "gh-cli";
}
export class GitHubClient {
private token: string | undefined;
private forceMode: "token" | "gh-cli" | undefined;
private baseUrl = "https://api.github.com";
private lastRequestTime = 0;
/**
* Create a GitHub client.
* @param token Optional GitHub token for REST API fallback when gh CLI is unavailable
* @param tokenOrOptions Optional token or options for transport behavior
*/
constructor(token?: string) {
this.token = token;
constructor(tokenOrOptions?: string | GitHubClientOptions) {
if (typeof tokenOrOptions === "string") {
this.token = tokenOrOptions;
this.forceMode = undefined;
return;
}
this.token = tokenOrOptions?.token;
this.forceMode = tokenOrOptions?.forceMode;
}
private hasGhAuth(): boolean {
return isGhAvailable() && isGhAuthenticated();
}
private requireToken(): string {
const token = this.token?.trim();
if (!token) {
throw new Error("GitHub client is forced to token mode, but no token is configured.");
}
return token;
}
private requireGh(): void {
if (!isGhAvailable()) {
throw new Error(getGhErrorMessage(new Error("gh CLI is not available.")));
}
if (!isGhAuthenticated()) {
throw new Error(getGhErrorMessage(new Error("gh CLI is not authenticated.")));
}
}
private resolveRepo(owner?: string, repo?: string): { owner: string; repo: string } {
if (owner && repo) {
return { owner, repo };
@@ -407,6 +442,16 @@ export class GitHubClient {
* to the REST API. Returns the created PR info.
*/
async createPr(params: CreatePrParams): Promise<PrInfo> {
if (this.forceMode === "gh-cli") {
this.requireGh();
return this.createPrWithGh(params);
}
if (this.forceMode === "token") {
this.requireToken();
return this.createPrWithApi(params);
}
// Try gh CLI first (preferred for auth handling)
if (this.hasGhAuth()) {
try {
@@ -428,6 +473,20 @@ export class GitHubClient {
}
async createIssue(params: CreateIssueParams): Promise<CreatedIssue> {
if (this.forceMode === "gh-cli") {
this.requireGh();
return this.createIssueWithGh(params);
}
if (this.forceMode === "token") {
this.requireToken();
try {
return await this.createIssueWithApi(params);
} catch (error) {
throw new Error(`Failed to create GitHub issue in ${params.owner}/${params.repo}`, { cause: error });
}
}
if (this.hasGhAuth()) {
try {
return await this.createIssueWithGh(params);
@@ -1308,7 +1367,23 @@ export class GitHubClient {
}
async commentOnIssue(owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
if (this.hasGhAuth()) {
if (this.forceMode === "gh-cli") {
this.requireGh();
runGh([
"issue",
"comment",
String(issueNumber),
"--repo",
`${owner}/${repo}`,
"--body",
body,
]);
return;
}
if (this.forceMode === "token") {
this.requireToken();
} else if (this.hasGhAuth()) {
try {
runGh([
"issue",
@@ -1355,7 +1430,20 @@ export class GitHubClient {
state: "open" | "closed",
stateReason?: "completed" | "not_planned" | "reopened",
): Promise<void> {
if (this.hasGhAuth()) {
if (this.forceMode === "gh-cli") {
this.requireGh();
const command = state === "closed" ? "close" : "reopen";
const args = ["issue", command, String(issueNumber), "--repo", `${owner}/${repo}`];
if (state === "closed" && (stateReason === "completed" || stateReason === "not_planned")) {
args.push("--reason", stateReason);
}
runGh(args);
return;
}
if (this.forceMode === "token") {
this.requireToken();
} else if (this.hasGhAuth()) {
try {
const command = state === "closed" ? "close" : "reopen";
const args = ["issue", command, String(issueNumber), "--repo", `${owner}/${repo}`];
@@ -1406,6 +1494,16 @@ export class GitHubClient {
repo: string,
number: number,
): Promise<Omit<import("@fusion/core").IssueInfo, "lastCheckedAt"> | null> {
if (this.forceMode === "gh-cli") {
this.requireGh();
return this.getIssueStatusWithGh(owner, repo, number);
}
if (this.forceMode === "token") {
this.requireToken();
return this.getIssueStatusWithApi(owner, repo, number);
}
if (this.hasGhAuth()) {
try {
return await this.getIssueStatusWithGh(owner, repo, number);
@@ -1416,7 +1514,7 @@ export class GitHubClient {
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getIssueStatusWithApi(owner, repo, number);
}

View File

@@ -11,8 +11,14 @@ export {
type RuntimeLogSink,
} from "./runtime-logger.js";
export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js";
export { GitHubClient, isPrMergeReady, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue } from "./github.js";
export { GitHubClient, isPrMergeReady, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue } from "./github.js";
export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js";
export {
resolveGithubTrackingAuth,
type GithubTrackingAuth,
type GithubTrackingAuthResolution,
type ResolveGithubTrackingAuthDeps,
} from "./github-auth.js";
export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";
export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js";
export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js";

View File

@@ -34,7 +34,6 @@ import {
} from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { verifyWebhookSignature } from "./github-webhooks.js";
import { GitHubClient } from "./github.js";
import { maybeCreateTrackingIssue } from "./github-tracking.js";
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
@@ -4798,15 +4797,9 @@ async function executeAiPromptStep(
async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task): Promise<void> {
const projectSettings = await taskStore.getSettings();
const globalSettings = (await taskStore.getGlobalSettingsStore?.()?.getSettings?.()) ?? {};
const authMode = projectSettings.githubAuthMode;
const token = authMode === "token"
? projectSettings.githubAuthToken
: undefined;
try {
await maybeCreateTrackingIssue(task, {
taskStore,
githubClient: new GitHubClient(token),
projectSettings,
globalSettings,
logger: console,

View File

@@ -1107,17 +1107,11 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
githubIssueCommentService.start();
ctx.registerDispose(() => githubIssueCommentService.stop());
const githubTrackingCommentService = new GitHubTrackingCommentService(
store,
() => ctx.options?.githubToken ?? process.env.GITHUB_TOKEN,
);
const githubTrackingCommentService = new GitHubTrackingCommentService(store);
githubTrackingCommentService.start();
ctx.registerDispose(() => githubTrackingCommentService.stop());
const githubTrackingStateService = new GitHubTrackingStateService(
store,
() => ctx.options?.githubToken ?? process.env.GITHUB_TOKEN,
);
const githubTrackingStateService = new GitHubTrackingStateService(store);
githubTrackingStateService.start();
ctx.registerDispose(() => githubTrackingStateService.stop());
}

View File

@@ -7,7 +7,6 @@ import {
type TaskStore,
} from "@fusion/core";
import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js";
import { GitHubClient } from "../github.js";
import { maybeCreateTrackingIssue } from "../github-tracking.js";
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
import type { AiSessionStore } from "../ai-session-store.js";
@@ -17,15 +16,9 @@ import { derivePerTaskBranch, resolveBranchAssignmentContext, resolveBranchSelec
async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: import("@fusion/core").Task): Promise<void> {
const projectSettings = await taskStore.getSettings();
const globalSettings = (await taskStore.getGlobalSettingsStore?.()?.getSettings?.()) ?? {};
const authMode = projectSettings.githubAuthMode;
const token = authMode === "token"
? projectSettings.githubAuthToken
: undefined;
try {
await maybeCreateTrackingIssue(task, {
taskStore,
githubClient: new GitHubClient(token),
projectSettings,
globalSettings,
logger: console,

View File

@@ -48,6 +48,7 @@ import { execFile } from "node:child_process";
import { homedir } from "node:os";
import { promisify } from "node:util";
import { ApiError, badRequest } from "../api-error.js";
import { resolveGithubTrackingAuth } from "../github-auth.js";
import { generateRemoteToken, issueRemoteAuthToken, maskRemoteToken } from "../remote-auth.js";
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
import type { ApiRoutesContext } from "./types.js";
@@ -375,10 +376,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettingsFast();
const prAuthAvailable = (isGhAvailable() && isGhAuthenticated()) || Boolean(githubToken);
const trackingAuthResolution = resolveGithubTrackingAuth({
projectSettings: {
githubAuthMode: settings.githubAuthMode,
githubAuthToken: settings.githubAuthToken,
},
globalSettings: {},
env: process.env,
});
// Inject server-side configuration flags
res.json({
...settings,
prAuthAvailable,
trackingAuthAvailable: trackingAuthResolution.ok,
trackingAuthReason: trackingAuthResolution.ok ? null : trackingAuthResolution.reason,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -394,7 +405,13 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
// Strip server-owned fields that should never be persisted to config.json.
// These are computed server-side and injected only on GET /settings.
const { githubTokenConfigured, prAuthAvailable, ...clientSettings } = req.body;
const {
githubTokenConfigured,
prAuthAvailable,
trackingAuthAvailable,
trackingAuthReason,
...clientSettings
} = req.body;
// Reject global-only fields with a helpful error pointing to the correct endpoint
const globalKeySet = new Set<string>(GLOBAL_SETTINGS_KEYS);

View File

@@ -6,6 +6,7 @@ import {
VALID_TRANSITIONS,
buildMeshReplicatedTaskCreatePayload,
isTaskPriority,
REPO_OVERRIDE_RE,
resolveTitleSummarizerSettingsModel,
toReplicatedCreateInput,
validateNodeOverrideChange,
@@ -100,16 +101,15 @@ async function buildDirectTaskReviewData(task: Task, store: TaskStore): Promise<
async function maybeCreateTaskTrackingIssue(taskStore: TaskStore, task: Task, optionsToken?: string): Promise<void> {
const projectSettings = await taskStore.getSettings();
const globalSettings = (await taskStore.getGlobalSettingsStore?.()?.getSettings?.()) ?? {};
const authMode = projectSettings.githubAuthMode;
const token = authMode === "token"
? projectSettings.githubAuthToken ?? optionsToken
: optionsToken;
const trackingProjectSettings = {
...projectSettings,
githubAuthToken: projectSettings.githubAuthToken ?? optionsToken,
};
try {
await maybeCreateTrackingIssue(task, {
taskStore,
githubClient: new GitHubClient(token),
projectSettings,
projectSettings: trackingProjectSettings,
globalSettings,
logger: console,
});
@@ -205,6 +205,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
baseBranch,
branchSelection,
nodeId,
githubTracking,
} = req.body;
if (!description || typeof description !== "string") {
throw badRequest("description is required");
@@ -301,6 +302,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const { branch: normalizedBranch, baseBranch: normalizedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch);
let validatedGithubTracking: { enabled?: boolean; repoOverride?: string } | undefined;
if (githubTracking !== undefined && githubTracking !== null) {
if (typeof githubTracking !== "object") {
throw badRequest("githubTracking must be an object");
}
const candidate = githubTracking as { enabled?: unknown; repoOverride?: unknown };
if (candidate.enabled !== undefined && typeof candidate.enabled !== "boolean") {
throw badRequest("githubTracking.enabled must be a boolean");
}
if (candidate.repoOverride !== undefined && typeof candidate.repoOverride !== "string") {
throw badRequest("githubTracking.repoOverride must be a string");
}
const trimmedRepoOverride = typeof candidate.repoOverride === "string" ? candidate.repoOverride.trim() : "";
if (trimmedRepoOverride.length > 0 && !REPO_OVERRIDE_RE.test(trimmedRepoOverride)) {
throw badRequest("githubTracking.repoOverride must be in 'owner/repo' format");
}
validatedGithubTracking = {
...(candidate.enabled !== undefined ? { enabled: candidate.enabled } : {}),
...(trimmedRepoOverride.length > 0 ? { repoOverride: trimmedRepoOverride } : {}),
};
}
const createInput = {
title,
description,
@@ -324,6 +347,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
branch: normalizedBranch,
baseBranch: normalizedBaseBranch,
...(typeof nodeId === "string" && nodeId.trim().length > 0 ? { nodeId: nodeId.trim() } : {}),
...(validatedGithubTracking ? { githubTracking: validatedGithubTracking } : {}),
};
if (typeof scopedStore.createTaskWithReservedId !== "function") {
@@ -1585,7 +1609,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.patch("/tasks/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch } = req.body;
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking } = req.body;
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
// Validate model fields are strings or undefined/null
@@ -1710,6 +1734,39 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const normalizedBranch = hasBodyField("branch") ? validatePatchBranchField(branch, "branch") : undefined;
const normalizedBaseBranch = hasBodyField("baseBranch") ? validatePatchBranchField(baseBranch, "baseBranch") : undefined;
let validatedGithubTracking: { enabled?: boolean; repoOverride?: string | null; issue?: null } | null | undefined;
if (hasBodyField("githubTracking")) {
if (githubTracking === null) {
validatedGithubTracking = null;
} else if (typeof githubTracking !== "object") {
throw new Error("githubTracking must be an object or null");
} else {
const candidate = githubTracking as { enabled?: unknown; repoOverride?: unknown; issue?: unknown };
if (candidate.enabled !== undefined && typeof candidate.enabled !== "boolean") {
throw new Error("githubTracking.enabled must be a boolean");
}
if (candidate.repoOverride !== undefined && candidate.repoOverride !== null && typeof candidate.repoOverride !== "string") {
throw new Error("githubTracking.repoOverride must be a string or null");
}
if (typeof candidate.repoOverride === "string") {
const trimmed = candidate.repoOverride.trim();
if (trimmed.length > 0 && !REPO_OVERRIDE_RE.test(trimmed)) {
throw badRequest("githubTracking.repoOverride must be in 'owner/repo' format");
}
}
if (candidate.issue !== undefined && candidate.issue !== null) {
throw new Error("githubTracking.issue only supports null for manual unlink");
}
const trimmedRepo = typeof candidate.repoOverride === "string" ? candidate.repoOverride.trim() : candidate.repoOverride;
validatedGithubTracking = {
...(candidate.enabled !== undefined ? { enabled: candidate.enabled } : {}),
...(candidate.repoOverride !== undefined ? { repoOverride: typeof trimmedRepo === "string" ? (trimmedRepo.length > 0 ? trimmedRepo : null) : null } : {}),
...(candidate.issue === null ? { issue: null } : {}),
};
}
}
const updates: Parameters<typeof scopedStore.updateTask>[1] = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
@@ -1731,6 +1788,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (hasBodyField("nodeId")) updates.nodeId = validatedNodeId;
if (hasBodyField("branch")) updates.branch = normalizedBranch;
if (hasBodyField("baseBranch")) updates.baseBranch = normalizedBaseBranch;
if (hasBodyField("githubTracking")) {
(updates as Record<string, unknown>).githubTracking = validatedGithubTracking;
}
if (hasBodyField("nodeId") && validatedNodeId !== undefined) {
const currentTask = await scopedStore.getTask(req.params.id);