feat(FN-2995): export research settings resolver for dashboard alias builds
Exports the research settings resolver from `@fusion/core` types to fix dashboard alias builds, ensuring the resolver is available when imported through the dashboard's module alias configuration. Fusion-Task-Id: FN-2995
This commit is contained in:
@@ -333,6 +333,45 @@ describe("GlobalSettingsStore", () => {
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.ntfyTopic).toMatch(/^topic-\d$/);
|
||||
});
|
||||
|
||||
it("persists and clears nested researchGlobalDefaults with null-as-delete semantics", async () => {
|
||||
await store.init();
|
||||
await store.updateSettings({
|
||||
researchGlobalDefaults: {
|
||||
searchProvider: "tavily",
|
||||
enabledSources: {
|
||||
webSearch: true,
|
||||
pageFetch: true,
|
||||
github: false,
|
||||
localDocs: true,
|
||||
llmSynthesis: true,
|
||||
},
|
||||
maxSourcesPerRun: 15,
|
||||
defaultExportFormat: "json",
|
||||
},
|
||||
});
|
||||
|
||||
let settings = await store.getSettings();
|
||||
expect(settings.researchGlobalDefaults?.searchProvider).toBe("tavily");
|
||||
|
||||
// @ts-expect-error null is intentionally used to clear field
|
||||
await store.updateSettings({ researchGlobalDefaults: null });
|
||||
settings = await store.getSettings();
|
||||
expect(settings.researchGlobalDefaults).toMatchObject({
|
||||
searchProvider: undefined,
|
||||
synthesisProvider: undefined,
|
||||
synthesisModelId: undefined,
|
||||
maxSourcesPerRun: 20,
|
||||
defaultExportFormat: "markdown",
|
||||
});
|
||||
expect(settings.researchGlobalDefaults?.enabledSources).toEqual({
|
||||
webSearch: true,
|
||||
pageFetch: true,
|
||||
github: false,
|
||||
localDocs: true,
|
||||
llmSynthesis: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema protection", () => {
|
||||
|
||||
116
packages/core/src/__tests__/research-settings.test.ts
Normal file
116
packages/core/src/__tests__/research-settings.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveResearchSettings } from "../research-settings.js";
|
||||
import type { Settings } from "../types.js";
|
||||
|
||||
describe("resolveResearchSettings", () => {
|
||||
it("resolves global-only defaults", () => {
|
||||
const resolved = resolveResearchSettings({
|
||||
researchGlobalDefaults: {
|
||||
searchProvider: "tavily",
|
||||
synthesisProvider: "anthropic",
|
||||
synthesisModelId: "claude-sonnet-4-5",
|
||||
enabledSources: {
|
||||
webSearch: true,
|
||||
pageFetch: true,
|
||||
github: false,
|
||||
localDocs: true,
|
||||
llmSynthesis: true,
|
||||
},
|
||||
maxSourcesPerRun: 12,
|
||||
defaultExportFormat: "json",
|
||||
},
|
||||
researchGlobalEnabled: true,
|
||||
});
|
||||
|
||||
expect(resolved.searchProvider).toBe("tavily");
|
||||
expect(resolved.synthesisProvider).toBe("anthropic");
|
||||
expect(resolved.synthesisModelId).toBe("claude-sonnet-4-5");
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.enabledSources.github).toBe(false);
|
||||
expect(resolved.limits.maxSourcesPerRun).toBe(12);
|
||||
expect(resolved.defaultExportFormat).toBe("json");
|
||||
});
|
||||
|
||||
it("project values override global values", () => {
|
||||
const resolved = resolveResearchSettings({
|
||||
researchGlobalDefaults: { searchProvider: "brave", maxSourcesPerRun: 10 },
|
||||
researchSettings: {
|
||||
enabled: false,
|
||||
searchProvider: "tavily",
|
||||
limits: { maxSourcesPerRun: 3, maxConcurrentRuns: 1, maxDurationMs: 1000, requestTimeoutMs: 500 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.enabled).toBe(false);
|
||||
expect(resolved.searchProvider).toBe("tavily");
|
||||
expect(resolved.limits.maxSourcesPerRun).toBe(3);
|
||||
expect(resolved.limits.maxConcurrentRuns).toBe(1);
|
||||
});
|
||||
|
||||
it("partial nested project overrides preserve remaining global defaults", () => {
|
||||
const resolved = resolveResearchSettings({
|
||||
researchGlobalDefaults: {
|
||||
enabledSources: {
|
||||
webSearch: true,
|
||||
pageFetch: true,
|
||||
github: true,
|
||||
localDocs: true,
|
||||
llmSynthesis: true,
|
||||
},
|
||||
},
|
||||
researchSettings: {
|
||||
enabledSources: { github: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.enabledSources.github).toBe(false);
|
||||
expect(resolved.enabledSources.webSearch).toBe(true);
|
||||
expect(resolved.enabledSources.pageFetch).toBe(true);
|
||||
});
|
||||
|
||||
it("clearing project override falls back to global/default", () => {
|
||||
const resolved = resolveResearchSettings({
|
||||
researchGlobalDefaults: {
|
||||
searchProvider: "brave",
|
||||
},
|
||||
researchSettings: {
|
||||
searchProvider: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.searchProvider).toBe("brave");
|
||||
});
|
||||
|
||||
it("supports null-as-delete semantics for researchSettings object", () => {
|
||||
const resolved = resolveResearchSettings({
|
||||
researchGlobalDefaults: {
|
||||
searchProvider: "tavily",
|
||||
},
|
||||
researchSettings: null as unknown as Settings["researchSettings"],
|
||||
});
|
||||
|
||||
expect(resolved.searchProvider).toBe("tavily");
|
||||
expect(resolved.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back through legacy and hardcoded limit chains", () => {
|
||||
const fromLegacyProjectTimeout = resolveResearchSettings({
|
||||
researchDefaultTimeout: 111_000,
|
||||
researchFetchTimeoutMs: 8_000,
|
||||
});
|
||||
expect(fromLegacyProjectTimeout.limits.maxDurationMs).toBe(111_000);
|
||||
expect(fromLegacyProjectTimeout.limits.requestTimeoutMs).toBe(8_000);
|
||||
|
||||
const fromLegacyGlobalTimeout = resolveResearchSettings({
|
||||
researchGlobalDefaultTimeout: 222_000,
|
||||
researchGlobalMaxConcurrentRuns: 9,
|
||||
});
|
||||
expect(fromLegacyGlobalTimeout.limits.maxDurationMs).toBe(222_000);
|
||||
expect(fromLegacyGlobalTimeout.limits.maxConcurrentRuns).toBe(9);
|
||||
|
||||
const hardcodedFallback = resolveResearchSettings({});
|
||||
expect(hardcodedFallback.limits.maxDurationMs).toBe(300000);
|
||||
expect(hardcodedFallback.limits.requestTimeoutMs).toBe(30000);
|
||||
expect(hardcodedFallback.defaultExportFormat).toBe("markdown");
|
||||
});
|
||||
});
|
||||
@@ -51,8 +51,11 @@ describe("settings key parity", () => {
|
||||
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
|
||||
expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true);
|
||||
expect(isProjectSettingsKey("remoteAccess")).toBe(true);
|
||||
expect(isProjectSettingsKey("researchSettings")).toBe(true);
|
||||
expect(isGlobalSettingsKey("researchGlobalDefaults")).toBe(true);
|
||||
expect(isProjectSettingsKey("themeMode")).toBe(false);
|
||||
expect(isGlobalSettingsKey("remoteAccess")).toBe(false);
|
||||
expect(isGlobalSettingsKey("researchSettings")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes heartbeatMultiplier in project defaults", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
@@ -620,6 +620,9 @@ export type {
|
||||
ResearchCancellationState,
|
||||
} from "./research-types.js";
|
||||
|
||||
export { resolveResearchSettings } from "./research-settings.js";
|
||||
export type { ResolvedResearchSettings } from "./research-settings.js";
|
||||
|
||||
export { TodoStore } from "./todo-store.js";
|
||||
export type { TodoStoreEvents } from "./todo-store.js";
|
||||
|
||||
|
||||
65
packages/core/src/research-settings.ts
Normal file
65
packages/core/src/research-settings.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ResearchEnabledSources, Settings } from "./types.js";
|
||||
|
||||
export interface ResolvedResearchSettings {
|
||||
enabled: boolean;
|
||||
searchProvider?: string;
|
||||
synthesisProvider?: string;
|
||||
synthesisModelId?: string;
|
||||
enabledSources: ResearchEnabledSources;
|
||||
limits: {
|
||||
maxConcurrentRuns: number;
|
||||
maxSourcesPerRun: number;
|
||||
maxDurationMs: number;
|
||||
requestTimeoutMs: number;
|
||||
};
|
||||
defaultExportFormat: "markdown" | "json";
|
||||
}
|
||||
|
||||
const FALLBACK_SOURCES: ResearchEnabledSources = {
|
||||
webSearch: true,
|
||||
pageFetch: true,
|
||||
github: false,
|
||||
localDocs: true,
|
||||
llmSynthesis: true,
|
||||
};
|
||||
|
||||
export function resolveResearchSettings(settings: Partial<Settings> | undefined): ResolvedResearchSettings {
|
||||
const globalDefaults = settings?.researchGlobalDefaults;
|
||||
const projectSettings = settings?.researchSettings;
|
||||
|
||||
return {
|
||||
enabled: projectSettings?.enabled ?? settings?.researchEnabled ?? settings?.researchGlobalEnabled ?? true,
|
||||
searchProvider: projectSettings?.searchProvider ?? globalDefaults?.searchProvider,
|
||||
synthesisProvider: projectSettings?.synthesisProvider ?? globalDefaults?.synthesisProvider,
|
||||
synthesisModelId: projectSettings?.synthesisModelId ?? globalDefaults?.synthesisModelId,
|
||||
enabledSources: {
|
||||
webSearch:
|
||||
projectSettings?.enabledSources?.webSearch ??
|
||||
globalDefaults?.enabledSources?.webSearch ??
|
||||
FALLBACK_SOURCES.webSearch,
|
||||
pageFetch:
|
||||
projectSettings?.enabledSources?.pageFetch ??
|
||||
globalDefaults?.enabledSources?.pageFetch ??
|
||||
FALLBACK_SOURCES.pageFetch,
|
||||
github:
|
||||
projectSettings?.enabledSources?.github ??
|
||||
globalDefaults?.enabledSources?.github ??
|
||||
FALLBACK_SOURCES.github,
|
||||
localDocs:
|
||||
projectSettings?.enabledSources?.localDocs ??
|
||||
globalDefaults?.enabledSources?.localDocs ??
|
||||
FALLBACK_SOURCES.localDocs,
|
||||
llmSynthesis:
|
||||
projectSettings?.enabledSources?.llmSynthesis ??
|
||||
globalDefaults?.enabledSources?.llmSynthesis ??
|
||||
FALLBACK_SOURCES.llmSynthesis,
|
||||
},
|
||||
limits: {
|
||||
maxConcurrentRuns: projectSettings?.limits?.maxConcurrentRuns ?? settings?.researchMaxConcurrentRuns ?? settings?.researchGlobalMaxConcurrentRuns ?? 3,
|
||||
maxSourcesPerRun: projectSettings?.limits?.maxSourcesPerRun ?? globalDefaults?.maxSourcesPerRun ?? settings?.researchMaxSourcesPerRun ?? settings?.researchGlobalMaxSourcesPerRun ?? 20,
|
||||
maxDurationMs: projectSettings?.limits?.maxDurationMs ?? settings?.researchDefaultTimeout ?? settings?.researchGlobalDefaultTimeout ?? 300000,
|
||||
requestTimeoutMs: projectSettings?.limits?.requestTimeoutMs ?? settings?.researchFetchTimeoutMs ?? 30000,
|
||||
},
|
||||
defaultExportFormat: globalDefaults?.defaultExportFormat ?? "markdown",
|
||||
};
|
||||
}
|
||||
@@ -64,6 +64,20 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
// Dashboard TUI memory guard
|
||||
vitestAutoKillEnabled: true,
|
||||
vitestKillThresholdPct: 90,
|
||||
researchGlobalDefaults: {
|
||||
searchProvider: undefined,
|
||||
synthesisProvider: undefined,
|
||||
synthesisModelId: undefined,
|
||||
enabledSources: {
|
||||
webSearch: true,
|
||||
pageFetch: true,
|
||||
github: false,
|
||||
localDocs: true,
|
||||
llmSynthesis: true,
|
||||
},
|
||||
maxSourcesPerRun: 20,
|
||||
defaultExportFormat: "markdown",
|
||||
},
|
||||
researchGlobalEnabled: true,
|
||||
researchGlobalMaxConcurrentRuns: 3,
|
||||
researchGlobalDefaultTimeout: 300000,
|
||||
@@ -228,6 +242,25 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
reflectionAfterTask: true,
|
||||
reviewHandoffPolicy: "disabled",
|
||||
showQuickChatFAB: false,
|
||||
researchSettings: {
|
||||
enabled: true,
|
||||
searchProvider: undefined,
|
||||
synthesisProvider: undefined,
|
||||
synthesisModelId: undefined,
|
||||
enabledSources: {
|
||||
webSearch: true,
|
||||
pageFetch: true,
|
||||
github: false,
|
||||
localDocs: true,
|
||||
llmSynthesis: true,
|
||||
},
|
||||
limits: {
|
||||
maxConcurrentRuns: 3,
|
||||
maxSourcesPerRun: 20,
|
||||
maxDurationMs: 300000,
|
||||
requestTimeoutMs: 30000,
|
||||
},
|
||||
},
|
||||
researchEnabled: true,
|
||||
researchMaxConcurrentRuns: 3,
|
||||
researchDefaultTimeout: 300000,
|
||||
|
||||
@@ -1190,6 +1190,39 @@ export interface DaemonTokenSettings {
|
||||
/** Web search backend for auto-research provider. */
|
||||
export type WebSearchBackend = "searxng" | "brave" | "google" | "tavily" | "none";
|
||||
|
||||
export interface ResearchEnabledSources {
|
||||
webSearch: boolean;
|
||||
pageFetch: boolean;
|
||||
github: boolean;
|
||||
localDocs: boolean;
|
||||
llmSynthesis: boolean;
|
||||
}
|
||||
|
||||
export interface ResearchGlobalDefaults {
|
||||
searchProvider?: string;
|
||||
synthesisProvider?: string;
|
||||
synthesisModelId?: string;
|
||||
enabledSources?: ResearchEnabledSources;
|
||||
maxSourcesPerRun?: number;
|
||||
defaultExportFormat?: "markdown" | "json";
|
||||
}
|
||||
|
||||
export interface ResearchProjectLimits {
|
||||
maxConcurrentRuns?: number;
|
||||
maxSourcesPerRun?: number;
|
||||
maxDurationMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ResearchProjectSettings {
|
||||
enabled?: boolean;
|
||||
searchProvider?: string;
|
||||
synthesisProvider?: string;
|
||||
synthesisModelId?: string;
|
||||
enabledSources?: Partial<ResearchEnabledSources>;
|
||||
limits?: ResearchProjectLimits;
|
||||
}
|
||||
|
||||
export interface GlobalSettings {
|
||||
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
|
||||
themeMode?: ThemeMode;
|
||||
@@ -1392,6 +1425,9 @@ export interface GlobalSettings {
|
||||
* triggers a vitest auto-kill. Clamped to [50, 99] in the UI.
|
||||
* Default: 90. */
|
||||
vitestKillThresholdPct?: number;
|
||||
/** Research defaults shared across all projects.
|
||||
* Project settings may override these via `researchSettings`. */
|
||||
researchGlobalDefaults?: ResearchGlobalDefaults;
|
||||
/** Enable or disable the research subsystem globally.
|
||||
* When false, dashboard/API entrypoints should reject new research runs.
|
||||
* Default: true when research store exists. */
|
||||
@@ -1555,8 +1591,11 @@ export interface ProjectSettings {
|
||||
* - "block": prevent execution until the selected node is healthy/available (default)
|
||||
* - "fallback-local": run on the local node when the selected node is unavailable */
|
||||
unavailableNodePolicy?: UnavailableNodePolicy;
|
||||
/** Project-level research configuration overrides. */
|
||||
researchSettings?: ResearchProjectSettings;
|
||||
/** Enable or disable the research subsystem for this project.
|
||||
* When undefined, falls back to global settings. */
|
||||
* When undefined, falls back to global settings.
|
||||
* @deprecated Prefer researchSettings.enabled */
|
||||
researchEnabled?: boolean;
|
||||
/** Project-level maximum concurrent research runs.
|
||||
* When undefined, falls back to global settings (default 3). */
|
||||
@@ -3560,3 +3599,5 @@ export {
|
||||
resolveValidatorSettingsModel,
|
||||
} from "./model-resolution.js";
|
||||
export type { ResolvedModelSelection } from "./model-resolution.js";
|
||||
export { resolveResearchSettings } from "./research-settings.js";
|
||||
export type { ResolvedResearchSettings } from "./research-settings.js";
|
||||
|
||||
Reference in New Issue
Block a user