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:
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() ?? "";
|
||||
|
||||
108
packages/dashboard/src/__tests__/github-auth.test.ts
Normal file
108
packages/dashboard/src/__tests__/github-auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
115
packages/dashboard/src/__tests__/github-forced-mode.test.ts
Normal file
115
packages/dashboard/src/__tests__/github-forced-mode.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
113
packages/dashboard/src/__tests__/github-tracking-auth.test.ts
Normal file
113
packages/dashboard/src/__tests__/github-tracking-auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
71
packages/dashboard/src/github-auth.ts
Normal file
71
packages/dashboard/src/github-auth.ts
Normal 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".`,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user