From 78a546f132e974686082a0262cb519ab986d907a Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 16:29:14 -0400 Subject: [PATCH 1/7] feat(actions): adds optional Actions tab toggle Add enableActions boolean to ConfigSchema (default true). When disabled: - Skip all workflow run API calls (full poll, targeted refresh, hot poll) - Hide Actions tab and actions-based custom tabs from TabBar - Suppress workflowRuns notifications and disable related settings - Surface 'Actions monitoring disabled' in MCP relay and server tools - Clear stale cached workflow data and hot run sets on disable - Reset notification and events state on re-enable --- mcp/src/data-source.ts | 32 +- mcp/src/index.ts | 1 + mcp/src/tools.ts | 13 +- .../components/dashboard/DashboardPage.tsx | 36 ++- src/app/components/layout/TabBar.tsx | 15 +- src/app/components/settings/SettingsPage.tsx | 46 ++- src/app/components/shared/CustomTabModal.tsx | 5 +- src/app/lib/mcp-relay.ts | 21 +- src/app/services/poll.ts | 58 +++- src/shared/schemas.ts | 1 + src/shared/types.ts | 4 +- tests/app/lib/mcp-relay.test.ts | 55 +++- tests/components/layout/TabBar.test.tsx | 35 +++ .../settings/actions-toggle.test.tsx | 284 ++++++++++++++++++ .../components/shared/CustomTabModal.test.tsx | 52 ++++ tests/services/events-poll.test.ts | 1 + tests/services/poll-fetchAllData.test.ts | 214 ++++++++++++- tests/stores/config.test.ts | 7 + 18 files changed, 816 insertions(+), 64 deletions(-) create mode 100644 tests/components/settings/actions-toggle.test.tsx diff --git a/mcp/src/data-source.ts b/mcp/src/data-source.ts index 932e772c..9bdb471b 100644 --- a/mcp/src/data-source.ts +++ b/mcp/src/data-source.ts @@ -26,6 +26,7 @@ export interface CachedConfig { trackedUsers: TrackedUser[]; upstreamRepos: RepoRef[]; monitoredRepos: RepoRef[]; + enableActions: boolean; } let _cachedConfig: CachedConfig | null = null; @@ -341,6 +342,7 @@ export class OctokitDataSource implements DataSource { } async getFailingActions(repo?: string): Promise { + if (_cachedConfig?.enableActions === false) return []; const repos = resolveRepos(repo); const pairs = repos.flatMap((r) => @@ -451,8 +453,9 @@ export class OctokitDataSource implements DataSource { const login = await this.getLogin(); const repos = _cachedConfig?.selectedRepos ?? []; + const actionsEnabled = _cachedConfig?.enableActions !== false; if (repos.length === 0) { - return { openPRCount: 0, openIssueCount: 0, failingRunCount: 0, needsReviewCount: 0, approvedUnmergedCount: 0 }; + return { openPRCount: 0, openIssueCount: 0, failingRunCount: actionsEnabled ? 0 : null, needsReviewCount: 0, approvedUnmergedCount: 0, actionsMonitoringDisabled: !actionsEnabled }; } const repoFilter = repos.map((r) => `repo:${r.owner}/${r.name}`).join("+"); @@ -463,7 +466,6 @@ export class OctokitDataSource implements DataSource { let needsReviewCount = 0; // REST search lacks reviewDecision data — approved count requires GraphQL (relay path only) const approvedUnmergedCount = 0; - let failingRunCount = 0; const [prResult, issueResult, reviewResult] = await Promise.allSettled([ this.octokit.request("GET /search/issues", { q: `is:pr+is:open${involvesPart}+${repoFilter}`, per_page: 1 }), @@ -487,21 +489,25 @@ export class OctokitDataSource implements DataSource { console.error("[mcp] getDashboardSummary review count error:", reviewResult.reason instanceof Error ? reviewResult.reason.message : String(reviewResult.reason)); } - const failingRunResults = await Promise.allSettled( - repos.map((r) => - this.octokit.request( - "GET /repos/{owner}/{repo}/actions/runs", - { owner: r.owner, repo: r.name, status: "failure", per_page: 5 } + let finalFailingRunCount: number | null = null; + if (actionsEnabled) { + const failingRunResults = await Promise.allSettled( + repos.map((r) => + this.octokit.request( + "GET /repos/{owner}/{repo}/actions/runs", + { owner: r.owner, repo: r.name, status: "failure", per_page: 5 } + ) ) - ) - ); - for (const settled of failingRunResults) { - if (settled.status === "fulfilled") { - failingRunCount += (settled.value.data as { total_count: number }).total_count; + ); + finalFailingRunCount = 0; + for (const settled of failingRunResults) { + if (settled.status === "fulfilled") { + finalFailingRunCount += (settled.value.data as { total_count: number }).total_count; + } } } - return { openPRCount, openIssueCount, failingRunCount, needsReviewCount, approvedUnmergedCount }; + return { openPRCount, openIssueCount, failingRunCount: finalFailingRunCount, needsReviewCount, approvedUnmergedCount, actionsMonitoringDisabled: !actionsEnabled }; } async getConfig(): Promise { diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 12895558..8c3ba279 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -34,6 +34,7 @@ const ConfigUpdatePayloadSchema = z.object({ trackedUsers: TrackedUserSchema.array().max(MAX_TRACKED_USERS).default([]), upstreamRepos: RepoRefSchema.array().max(MAX_REPOS).default([]), monitoredRepos: RepoRefSchema.array().max(MAX_MONITORED_REPOS).default([]), + enableActions: z.boolean().default(true), }); // ── Main entry point ────────────────────────────────────────────────────────── diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts index 6a951b58..efc8b675 100644 --- a/mcp/src/tools.ts +++ b/mcp/src/tools.ts @@ -4,7 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { METHODS } from "../../src/shared/protocol.js"; -import type { DataSource } from "./data-source.js"; +import type { DataSource, CachedConfig } from "./data-source.js"; import type { Issue, PullRequest, @@ -68,12 +68,15 @@ function formatRun(run: WorkflowRun, index: number): string { } function formatSummary(summary: DashboardSummary, scope: string): string { + const failingLine = summary.failingRunCount === null + ? "Failing CI Runs: — (Actions monitoring disabled)" + : `Failing CI Runs: ${summary.failingRunCount}`; const lines: string[] = [ `GitHub Tracker Dashboard Summary (scope: ${scope})`, "─".repeat(50), `Open PRs: ${summary.openPRCount}`, `Open Issues: ${summary.openIssueCount}`, - `Failing CI Runs: ${summary.failingRunCount}`, + failingLine, `Needs Review: ${summary.needsReviewCount}`, `Approved/Unmerged: ${summary.approvedUnmergedCount}`, ]; @@ -186,6 +189,12 @@ export function registerTools(server: McpServer, dataSource: DataSource): void { async (args) => { const { repo } = args as { repo?: string }; try { + // SEC-010: check config before calling getFailingActions to surface disabled state + const cachedConfig: CachedConfig | null = await dataSource.getConfig(); + if (cachedConfig?.enableActions === false) { + const text = "GitHub Actions monitoring is disabled in the dashboard. Enable it in Settings to track workflow runs." + stalenessLine(); + return { content: [{ type: "text" as const, text }] }; + } const runs = await dataSource.getFailingActions(repo); if (runs.length === 0) { const text = `No failing or in-progress workflow runs found${repo ? ` in ${repo}` : ""}.` + stalenessLine(); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 15e9e1ef..3130e089 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -532,6 +532,8 @@ export default function DashboardPage() { const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab; if (tab === "tracked" && !config.enableTracking) return "issues"; if (tab === "jiraAssigned" && !config.jira?.enabled) return "issues"; + if (tab === "actions" && !config.enableActions) return "issues"; + if (!config.enableActions && !isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions") return "issues"; // Validate custom tab still exists; fall back to "issues" if stale if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return "issues"; return tab; @@ -542,6 +544,10 @@ export default function DashboardPage() { function handleTabChange(tab: TabId) { // Reject invalid tab IDs to prevent persisting stale state if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return; + if (!config.enableActions) { + if (tab === "actions") return; + if (!isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions") return; + } setActiveTab(tab); updateViewState({ lastActiveTab: tab }); } @@ -569,6 +575,15 @@ export default function DashboardPage() { } }); + // Redirect away from Actions tab (or actions-based custom tab) when Actions is disabled + createEffect(() => { + if (!config.enableActions) { + const tab = activeTab(); + const isActionsTab = tab === "actions" || (!isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions"); + if (isActionsTab) handleTabChange("issues"); + } + }); + // Clear stale Jira data when auth is cleared (e.g., 401 during token refresh) createEffect(() => { if (!isJiraAuthenticated()) { @@ -792,6 +807,7 @@ export default function DashboardPage() { const currentTabId = activeTab(); const result: Record = {}; for (const tab of config.customTabs) { + if (!config.enableActions && tab.baseType === "actions") continue; if (!tab.exclusive && tab.id !== currentTabId) continue; const matchesScope = buildTabScopeMatcher(tab); result[tab.id] = { @@ -866,6 +882,7 @@ export default function DashboardPage() { const users = allUsers(); const customCounts: Record = {}; for (const tab of config.customTabs) { + if (!config.enableActions && tab.baseType === "actions") continue; // customTabData skips non-exclusive inactive tabs (perf optimization), // so compute scope on demand for tabs absent from the memo. let data = customTabData()[tab.id]; @@ -968,9 +985,9 @@ export default function DashboardPage() { pullRequests: visiblePullRequests().filter((p) => isPrVisible(p, { ignoredIds: ignoredPRs, globalFilter: builtinFilter }) ).length, - actions: visibleWorkflowRuns().filter((w) => + ...(config.enableActions ? { actions: visibleWorkflowRuns().filter((w) => isRunVisible(w, { ignoredIds: ignoredRuns, showPrRuns: viewState.showPrRuns, globalFilter: builtinFilter }) - ).length, + ).length } : {}), ...(config.enableTracking ? { tracked: viewState.trackedItems.length } : {}), ...(config.jira?.enabled ? (() => { const f = viewState.tabFilters.jiraAssigned; @@ -1053,6 +1070,14 @@ export default function DashboardPage() { { defer: true } )); + // When Actions is disabled, clear stale workflowRuns from the store so memos + // computing against empty workflowRuns don't process cached data (SEC-004). + createEffect(() => { + if (!config.enableActions) { + setDashboardData(produce((d) => { d.workflowRuns = []; })); + } + }); + // Push dashboard data into the MCP relay snapshot on each full refresh. // Tracks lastRefreshedAt (always updated alongside data arrays in pollFetch). // Hot poll updates are intentionally excluded — relay reflects full-refresh data only. @@ -1064,6 +1089,7 @@ export default function DashboardPage() { issues: d.issues, pullRequests: d.pullRequests, workflowRuns: d.workflowRuns, + enableActions: config.enableActions, lastUpdatedAt: Date.now(), }); }); @@ -1092,8 +1118,9 @@ export default function DashboardPage() { onTabChange={handleTabChange} counts={tabCounts()} enableTracking={config.enableTracking} + enableActions={config.enableActions} enableJira={!!config.jira?.enabled} - customTabs={config.customTabs.map((t) => ({ id: t.id, name: t.name }))} + customTabs={config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ id: t.id, name: t.name }))} onAddTab={() => setShowCustomTabModal(true)} onEditTab={(id) => { setEditingTabId(id); setShowCustomTabModal(true); }} /> @@ -1156,7 +1183,7 @@ export default function DashboardPage() { siteUrl={config.jira?.siteUrl ?? ""} /> - + r.owner))]} availableRepos={config.selectedRepos} + enableActions={config.enableActions} /> diff --git a/src/app/components/layout/TabBar.tsx b/src/app/components/layout/TabBar.tsx index 7c21652a..610f275d 100644 --- a/src/app/components/layout/TabBar.tsx +++ b/src/app/components/layout/TabBar.tsx @@ -11,6 +11,7 @@ interface TabBarProps { onTabChange: (tab: TabId) => void; counts?: TabCounts; enableTracking?: boolean; + enableActions?: boolean; enableJira?: boolean; customTabs?: Array<{ id: string; name: string }>; onAddTab?: () => void; @@ -36,12 +37,14 @@ export default function TabBar(props: TabBarProps) { {props.counts?.pullRequests} - - Actions - - {props.counts?.actions} - - + + + Actions + + {props.counts?.actions} + + + Tracked diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index d4fef81c..28e7bcc1 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -177,6 +177,7 @@ export default function SettingsPage() { defaultTab: config.defaultTab, rememberLastTab: config.rememberLastTab, enableTracking: config.enableTracking, + enableActions: config.enableActions, customTabs: config.customTabs, // Non-secret jira config fields only — no tokens, sealed blobs, or email jira: { @@ -345,10 +346,10 @@ export default function SettingsPage() { const tabOptions = createMemo(() => [ { value: "issues", label: "Issues" }, { value: "pullRequests", label: "Pull Requests" }, - { value: "actions", label: "GitHub Actions" }, + ...(config.enableActions ? [{ value: "actions", label: "GitHub Actions" }] : []), ...(config.enableTracking ? [{ value: "tracked", label: "Tracked Items" }] : []), ...(config.jira?.enabled ? [{ value: "jiraAssigned", label: "Jira" }] : []), - ...config.customTabs.map((t) => ({ value: t.id, label: t.name })), + ...config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ value: t.id, label: t.name })), ]); @@ -607,6 +608,34 @@ export default function SettingsPage() { {/* Section 5: GitHub Actions */}
+ + { + const val = e.currentTarget.checked; + const isActionsCustomTab = (id: string) => + config.customTabs.some((t) => t.id === id && t.baseType === "actions"); + const needsDefaultReset = !val && (config.defaultTab === "actions" || isActionsCustomTab(config.defaultTab)); + const needsLastTabReset = !val && (viewState.lastActiveTab === "actions" || isActionsCustomTab(viewState.lastActiveTab)); + saveWithFeedback({ + enableActions: val, + ...(needsDefaultReset ? { defaultTab: "issues" as const } : {}), + ...(!val ? { notifications: { ...config.notifications, workflowRuns: false } } : {}), + }); + if (needsLastTabReset) { + updateViewState({ lastActiveTab: "issues" }); + } + }} + class="toggle toggle-primary" + /> + { const val = parseInt(e.currentTarget.value, 10); if (!isNaN(val) && val >= 1 && val <= 20) { saveWithFeedback({ maxWorkflowsPerRepo: val }); } }} - class="input input-sm w-20" + class={`input input-sm w-20${!config.enableActions ? " opacity-50" : ""}`} /> { const val = parseInt(e.currentTarget.value, 10); if (!isNaN(val) && val >= 1 && val <= 10) { saveWithFeedback({ maxRunsPerWorkflow: val }); } }} - class="input input-sm w-20" + class={`input input-sm w-20${!config.enableActions ? " opacity-50" : ""}`} />
@@ -719,14 +750,17 @@ export default function SettingsPage() { class="toggle toggle-primary" /> - + saveWithFeedback({ notifications: { ...config.notifications, workflowRuns: e.currentTarget.checked }, diff --git a/src/app/components/shared/CustomTabModal.tsx b/src/app/components/shared/CustomTabModal.tsx index 58639d69..870106a4 100644 --- a/src/app/components/shared/CustomTabModal.tsx +++ b/src/app/components/shared/CustomTabModal.tsx @@ -19,6 +19,7 @@ interface CustomTabModalProps { editingTab?: CustomTab; availableOrgs: string[]; availableRepos: RepoRef[]; + enableActions?: boolean; } // Filter groups per base type — scope is included for issues/PRs since custom tabs always show it @@ -241,7 +242,9 @@ export default function CustomTabModal(props: CustomTabModalProps) { > - + + + diff --git a/src/app/lib/mcp-relay.ts b/src/app/lib/mcp-relay.ts index 4309fc58..ea4bc127 100644 --- a/src/app/lib/mcp-relay.ts +++ b/src/app/lib/mcp-relay.ts @@ -14,6 +14,7 @@ interface RelaySnapshot { issues: Issue[]; pullRequests: PullRequest[]; workflowRuns: WorkflowRun[]; + enableActions: boolean; lastUpdatedAt: number; } @@ -53,6 +54,7 @@ export function updateRelaySnapshot(data: { issues: Issue[]; pullRequests: PullRequest[]; workflowRuns: WorkflowRun[]; + enableActions: boolean; lastUpdatedAt: number; }): void { _snapshot = { ...data }; @@ -90,6 +92,7 @@ function sendConfigUpdate(ws: WebSocket): void { trackedUsers: config.trackedUsers, upstreamRepos: config.upstreamRepos, monitoredRepos: config.monitoredRepos, + enableActions: config.enableActions, }, }; ws.send(JSON.stringify(notification)); @@ -123,9 +126,11 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { const result = { openPRCount: openPRs.length, openIssueCount: s.issues.filter((i) => i.state === "OPEN").length, - failingRunCount: s.workflowRuns.filter( - (r) => r.conclusion === "failure" || r.conclusion === "timed_out" - ).length, + // SEC-010: null when Actions monitoring is disabled so AI clients don't interpret 0 as "all clear" + failingRunCount: s.enableActions + ? s.workflowRuns.filter((r) => r.conclusion === "failure" || r.conclusion === "timed_out").length + : null, + actionsMonitoringDisabled: !s.enableActions, needsReviewCount: openPRs.filter((p) => p.reviewDecision === "REVIEW_REQUIRED").length, approvedUnmergedCount: openPRs.filter((p) => p.reviewDecision === "APPROVED").length, }; @@ -172,6 +177,15 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { } case METHODS.GET_FAILING_ACTIONS: { + // SEC-010: surface disabled state rather than returning empty array (false "all clear") + if (!snapshot!.enableActions) { + sendResponse(ws, { + jsonrpc: "2.0", + id, + result: { disabled: true, message: "GitHub Actions monitoring is disabled in the dashboard. Enable it in Settings to track workflow runs." }, + }); + break; + } const params = req.params ?? {}; let runs = snapshot!.workflowRuns.filter( (r) => r.status === "in_progress" || r.conclusion === "failure" || r.conclusion === "timed_out" @@ -376,6 +390,7 @@ export function initMcpRelay(): void { void config.trackedUsers; void config.upstreamRepos; void config.monitoredRepos; + void config.enableActions; if (_ws && _ws.readyState === WebSocket.OPEN) { sendConfigUpdate(_ws); diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index bbd7a9b0..0537a0d8 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -96,6 +96,8 @@ let _trackedUsersMounted = false; let _trackedUsersKey = ""; let _monitoredReposMounted = false; let _monitoredReposKey = ""; +let _enableActionsMounted = false; +let _enableActionsKey = true; createRoot(() => { createEffect(() => { const key = user()?.login ?? ""; @@ -141,6 +143,26 @@ createRoot(() => { }); } }); + + createEffect(() => { + const key = config.enableActions; + if (!_enableActionsMounted) { + _enableActionsMounted = true; + _enableActionsKey = key; + return; + } + if (key !== _enableActionsKey) { + _enableActionsKey = key; + untrack(() => { + if (key) { + _resetNotificationState(); + resetEventsState(); + } else { + clearHotSets(); + } + }); + } + }); }); // ── fetchAllData orchestrator ───────────────────────────────────────────────── @@ -191,7 +213,9 @@ export async function fetchAllData( errors: lightData.errors, }); } : undefined, trackedUsers, monitoredRepos), - fetchWorkflowRuns(octokit, selectedRepos, config.maxWorkflowsPerRepo, config.maxRunsPerWorkflow), + config.enableActions + ? fetchWorkflowRuns(octokit, selectedRepos, config.maxWorkflowsPerRepo, config.maxRunsPerWorkflow) + : Promise.resolve({ workflowRuns: [] as WorkflowRun[], errors: [] as ApiError[] }), ]); // Collect top-level errors (total function failures) @@ -684,21 +708,22 @@ export async function fetchTargetedRepoData( return { issues: [], pullRequests: [], workflowRuns: [], errors: [] }; } - // Record cooldown timestamps + // Record cooldown timestamps for ALL entries before filtering for (const [key] of entries) { _repoLastTargeted.set(key, now); } - const targetRepos = entries - .map(([, summary]) => { - const parts = summary.repoFullName.split("/"); - if (parts.length !== 2) return null; - return { owner: parts[0], name: parts[1], fullName: summary.repoFullName }; - }) - .filter((r): r is NonNullable => r !== null); + // When Actions is disabled, skip repos whose only event activity is workflow-related. + // These repos have no issue/PR changes to refresh — fetching them wastes API calls. + const filteredEntries = config.enableActions + ? entries + : entries.filter(([, summary]) => summary.hasIssueActivity || summary.hasPRActivity); - const workflowRepos = entries - .filter(([, summary]) => summary.hasWorkflowActivity) + if (filteredEntries.length === 0) { + return { issues: [], pullRequests: [], workflowRuns: [], errors: [] }; + } + + const targetRepos = filteredEntries .map(([, summary]) => { const parts = summary.repoFullName.split("/"); if (parts.length !== 2) return null; @@ -706,6 +731,17 @@ export async function fetchTargetedRepoData( }) .filter((r): r is NonNullable => r !== null); + const workflowRepos = config.enableActions + ? entries + .filter(([, summary]) => summary.hasWorkflowActivity) + .map(([, summary]) => { + const parts = summary.repoFullName.split("/"); + if (parts.length !== 2) return null; + return { owner: parts[0], name: parts[1], fullName: summary.repoFullName }; + }) + .filter((r): r is NonNullable => r !== null) + : []; + const [issuesAndPrsResult, runResult] = await Promise.allSettled([ fetchIssuesAndPullRequests(octokit, targetRepos, userLogin), workflowRepos.length > 0 diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 455aef5d..e629f53a 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -100,6 +100,7 @@ export const ConfigSchema = z.object({ onboardingComplete: z.boolean().default(false), authMethod: z.enum(["oauth", "pat"]).default("oauth"), enableTracking: z.boolean().default(false), + enableActions: z.boolean().default(true), customTabs: z.array(CustomTabSchema).max(10).default([]), mcpRelayEnabled: z.boolean().default(false), mcpRelayPort: z.number().int().min(1024).max(65535).default(9876), diff --git a/src/shared/types.ts b/src/shared/types.ts index 4cd0bcff..85016f68 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -114,7 +114,9 @@ export interface RateLimitInfo { export interface DashboardSummary { openPRCount: number; openIssueCount: number; - failingRunCount: number; + /** null when Actions monitoring is disabled (SEC-010: avoids false "all clear") */ + failingRunCount: number | null; + actionsMonitoringDisabled?: boolean; needsReviewCount: number; approvedUnmergedCount: number; } diff --git a/tests/app/lib/mcp-relay.test.ts b/tests/app/lib/mcp-relay.test.ts index 9c296191..b969c009 100644 --- a/tests/app/lib/mcp-relay.test.ts +++ b/tests/app/lib/mcp-relay.test.ts @@ -162,7 +162,7 @@ describe("updateRelaySnapshot / handleRequest", () => { const prs = [makePullRequest({ state: "OPEN", repoFullName: "owner/repo" })]; const runs = [makeWorkflowRun({ conclusion: "success" })]; - mod.updateRelaySnapshot({ issues, pullRequests: prs, workflowRuns: runs, lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues, pullRequests: prs, workflowRuns: runs, enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -210,6 +210,7 @@ describe("updateRelaySnapshot / handleRequest", () => { issues: [], pullRequests: [], workflowRuns: [], + enableActions: true, lastUpdatedAt: Date.now(), }); @@ -265,7 +266,7 @@ describe("GET_DASHBOARD_SUMMARY handler", () => { makeWorkflowRun({ conclusion: "success" }), ]; - mod.updateRelaySnapshot({ issues, pullRequests: prs, workflowRuns: runs, lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues, pullRequests: prs, workflowRuns: runs, enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -297,6 +298,24 @@ describe("GET_DASHBOARD_SUMMARY handler", () => { expect(parsed.result.needsReviewCount).toBe(1); expect(parsed.result.approvedUnmergedCount).toBe(1); }); + + it("returns failingRunCount=null and actionsMonitoringDisabled=true when enableActions is false", () => { + const issues = [makeIssue({ state: "OPEN" })]; + const runs = [makeWorkflowRun({ conclusion: "failure" })]; + mod.updateRelaySnapshot({ issues, pullRequests: [], workflowRuns: runs, enableActions: false, lastUpdatedAt: Date.now() }); + + const responses: string[] = []; + ws.send = vi.fn((data: string) => responses.push(data)); + mod.connectRelay(9876); + ws._triggerOpen(); + ws._triggerMessage(JSON.stringify({ jsonrpc: "2.0", id: 11, method: "get_dashboard_summary", params: { scope: "involves_me" } })); + + const response = responses.find((r) => (JSON.parse(r) as { id?: number }).id === 11); + const parsed = JSON.parse(response!) as { result: { failingRunCount: number | null; actionsMonitoringDisabled?: boolean; openIssueCount: number } }; + expect(parsed.result.failingRunCount).toBeNull(); + expect(parsed.result.actionsMonitoringDisabled).toBe(true); + expect(parsed.result.openIssueCount).toBe(1); + }); }); describe("GET_OPEN_PRS repo filter", () => { @@ -317,7 +336,7 @@ describe("GET_OPEN_PRS repo filter", () => { it("filters by repo when repo param is provided", () => { const pr1 = makePullRequest({ state: "OPEN", repoFullName: "owner/repo-a" }); const pr2 = makePullRequest({ state: "OPEN", repoFullName: "owner/repo-b" }); - mod.updateRelaySnapshot({ issues: [], pullRequests: [pr1, pr2], workflowRuns: [], lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues: [], pullRequests: [pr1, pr2], workflowRuns: [], enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -342,7 +361,7 @@ describe("GET_OPEN_PRS repo filter", () => { makePullRequest({ state: "OPEN" }), makePullRequest({ state: "CLOSED" }), ]; - mod.updateRelaySnapshot({ issues: [], pullRequests: prs, workflowRuns: [], lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues: [], pullRequests: prs, workflowRuns: [], enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -379,7 +398,7 @@ describe("GET_PR_DETAILS handler", () => { it("returns PR by repo+number", () => { const pr = makePullRequest({ number: 42, repoFullName: "owner/repo", state: "OPEN" }); - mod.updateRelaySnapshot({ issues: [], pullRequests: [pr], workflowRuns: [], lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues: [], pullRequests: [pr], workflowRuns: [], enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -399,7 +418,7 @@ describe("GET_PR_DETAILS handler", () => { }); it("returns result: null when PR not found", () => { - mod.updateRelaySnapshot({ issues: [], pullRequests: [], workflowRuns: [], lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues: [], pullRequests: [], workflowRuns: [], enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -421,7 +440,7 @@ describe("GET_PR_DETAILS handler", () => { it("returns PR by numeric id", () => { const pr = makePullRequest({ state: "OPEN" }); - mod.updateRelaySnapshot({ issues: [], pullRequests: [pr], workflowRuns: [], lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues: [], pullRequests: [pr], workflowRuns: [], enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -457,7 +476,7 @@ describe("GET_OPEN_PRS status filter", () => { }); function setupAndConnect(prs: ReturnType[]) { - mod.updateRelaySnapshot({ issues: [], pullRequests: prs, workflowRuns: [], lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues: [], pullRequests: prs, workflowRuns: [], enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); mod.connectRelay(9876); @@ -528,7 +547,7 @@ describe("GET_OPEN_ISSUES handler", () => { it("returns open issues", () => { const issues = [makeIssue({ state: "OPEN" }), makeIssue({ state: "OPEN" }), makeIssue({ state: "CLOSED" })]; - mod.updateRelaySnapshot({ issues, pullRequests: [], workflowRuns: [], lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues, pullRequests: [], workflowRuns: [], enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -541,7 +560,7 @@ describe("GET_OPEN_ISSUES handler", () => { it("filters by repo", () => { const issues = [makeIssue({ state: "OPEN", repoFullName: "owner/a" }), makeIssue({ state: "OPEN", repoFullName: "owner/b" })]; - mod.updateRelaySnapshot({ issues, pullRequests: [], workflowRuns: [], lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues, pullRequests: [], workflowRuns: [], enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -575,7 +594,7 @@ describe("GET_FAILING_ACTIONS handler", () => { makeWorkflowRun({ status: "completed", conclusion: "timed_out" }), makeWorkflowRun({ status: "completed", conclusion: "success" }), ]; - mod.updateRelaySnapshot({ issues: [], pullRequests: [], workflowRuns: runs, lastUpdatedAt: Date.now() }); + mod.updateRelaySnapshot({ issues: [], pullRequests: [], workflowRuns: runs, enableActions: true, lastUpdatedAt: Date.now() }); const responses: string[] = []; ws.send = vi.fn((data: string) => responses.push(data)); @@ -585,6 +604,20 @@ describe("GET_FAILING_ACTIONS handler", () => { const parsed = JSON.parse(responses.find((r) => (JSON.parse(r) as { id?: number }).id === 60)!) as { result: unknown[] }; expect(parsed.result).toHaveLength(3); }); + + it("returns disabled message when enableActions is false", () => { + const runs = [makeWorkflowRun({ status: "completed", conclusion: "failure" })]; + mod.updateRelaySnapshot({ issues: [], pullRequests: [], workflowRuns: runs, enableActions: false, lastUpdatedAt: Date.now() }); + + const responses: string[] = []; + ws.send = vi.fn((data: string) => responses.push(data)); + mod.connectRelay(9876); + ws._triggerOpen(); + ws._triggerMessage(JSON.stringify({ jsonrpc: "2.0", id: 61, method: "get_failing_actions", params: {} })); + const parsed = JSON.parse(responses.find((r) => (JSON.parse(r) as { id?: number }).id === 61)!) as { result: { disabled: boolean; message: string } }; + expect(parsed.result.disabled).toBe(true); + expect(parsed.result.message).toContain("disabled"); + }); }); describe("GET_CONFIG handler", () => { diff --git a/tests/components/layout/TabBar.test.tsx b/tests/components/layout/TabBar.test.tsx index c5dfb72d..fd17fc09 100644 --- a/tests/components/layout/TabBar.test.tsx +++ b/tests/components/layout/TabBar.test.tsx @@ -276,4 +276,39 @@ describe("TabBar", () => { )); expect(screen.queryByRole("button", { name: /Edit Alpha/i })).toBeNull(); }); + + // ── enableActions prop ─────────────────────────────────────────────────────── + + it("renders Actions tab when enableActions is true", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + expect(screen.getByRole("tab", { name: /Actions/ })).toBeDefined(); + }); + + it("renders Actions tab when enableActions is omitted (default visible)", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + expect(screen.getByRole("tab", { name: /Actions/ })).toBeDefined(); + }); + + it("hides Actions tab when enableActions is false", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + expect(screen.queryByRole("tab", { name: /Actions/ })).toBeNull(); + }); + + it("still shows Issues and Pull Requests tabs when enableActions is false", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + expect(screen.getByRole("tab", { name: /Issues/ })).toBeDefined(); + expect(screen.getByRole("tab", { name: /Pull Requests/ })).toBeDefined(); + }); }); diff --git a/tests/components/settings/actions-toggle.test.tsx b/tests/components/settings/actions-toggle.test.tsx new file mode 100644 index 00000000..ea96baa2 --- /dev/null +++ b/tests/components/settings/actions-toggle.test.tsx @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { screen, fireEvent } from "@solidjs/testing-library"; + +// ── localStorage mock ──────────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("../../../src/app/stores/auth", () => ({ + clearAuth: vi.fn(), + clearJiraAuth: vi.fn(), + setJiraAuth: vi.fn(), + jiraAuth: () => null, + isJiraAuthenticated: () => false, + ensureJiraTokenValid: vi.fn(), + token: () => "fake-token", + user: () => ({ login: "testuser", name: "Test User" }), + onAuthCleared: vi.fn(), +})); + +vi.mock("@sentry/solid", () => ({ + captureException: vi.fn(), + withSentryErrorBoundary: vi.fn((c: unknown) => c), +})); + +vi.mock("../../../src/app/stores/cache", () => ({ + clearCache: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../src/app/services/github", () => ({ + getClient: vi.fn(() => ({})), + onApiRequest: vi.fn(), +})); + +vi.mock("../../../src/app/services/api", () => ({ + fetchOrgs: vi.fn().mockResolvedValue([]), + fetchRepos: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../../src/app/lib/url", () => ({ + isSafeGitHubUrl: vi.fn(() => true), + openGitHubUrl: vi.fn(), +})); + +vi.mock("../../../src/app/lib/errors", () => ({ + pushNotification: vi.fn(), +})); + +// ── Imports after mocks ────────────────────────────────────────────────────── + +import { render } from "@solidjs/testing-library"; +import { MemoryRouter, Route } from "@solidjs/router"; +import SettingsPage from "../../../src/app/components/settings/SettingsPage"; +import { updateConfig, config } from "../../../src/app/stores/config"; +import { viewState, updateViewState } from "../../../src/app/stores/view"; +import * as urlModule from "../../../src/app/lib/url"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function renderSettings() { + return render(() => ( + + + + )); +} + +function setupMatchMedia() { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +// ── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + setupMatchMedia(); + vi.clearAllMocks(); + vi.mocked(urlModule.isSafeGitHubUrl).mockReturnValue(true); + + updateConfig({ + refreshInterval: 300, + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + theme: "light", + viewDensity: "comfortable", + itemsPerPage: 25, + defaultTab: "issues", + rememberLastTab: true, + enableActions: true, + notifications: { enabled: true, issues: true, pullRequests: true, workflowRuns: true }, + selectedOrgs: [], + selectedRepos: [], + authMethod: "oauth" as const, + }); + + updateViewState({ lastActiveTab: "issues" }); + sessionStorage.clear(); + localStorageMock.clear(); + + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: { reload: vi.fn(), href: "", origin: "http://localhost" }, + }); + + Object.defineProperty(window, "Notification", { + writable: true, + value: { permission: "default", requestPermission: vi.fn().mockResolvedValue("granted") }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("Actions enable toggle", () => { + it("renders checked by default (enableActions defaults to true)", () => { + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }) as HTMLInputElement; + expect(toggle.checked).toBe(true); + }); + + it("toggling off sets enableActions to false", () => { + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + fireEvent.click(toggle); + expect(config.enableActions).toBe(false); + }); + + it("toggling off resets defaultTab to 'issues' when it was 'actions'", () => { + updateConfig({ defaultTab: "actions" }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + fireEvent.click(toggle); + expect(config.defaultTab).toBe("issues"); + }); + + it("toggling off resets lastActiveTab to 'issues' when it was 'actions'", () => { + updateViewState({ lastActiveTab: "actions" }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + fireEvent.click(toggle); + expect(viewState.lastActiveTab).toBe("issues"); + }); + + it("toggling off suppresses workflowRuns notification", () => { + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + fireEvent.click(toggle); + expect(config.notifications.workflowRuns).toBe(false); + }); + + it("re-enable does NOT auto-restore workflowRuns notification", () => { + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + // Disable + fireEvent.click(toggle); + expect(config.notifications.workflowRuns).toBe(false); + // Re-enable + fireEvent.click(toggle); + expect(config.notifications.workflowRuns).toBe(false); + }); +}); + +describe("Actions settings controls disabled state", () => { + it("max workflows and max runs inputs are disabled when enableActions is false", () => { + updateConfig({ enableActions: false }); + renderSettings(); + const spinbuttons = screen.getAllByRole("spinbutton") as HTMLInputElement[]; + const actionsInputs = spinbuttons.filter((el) => el.classList.contains("opacity-50")); + expect(actionsInputs.length).toBeGreaterThanOrEqual(2); + actionsInputs.forEach((input) => expect(input.disabled).toBe(true)); + }); + + it("max workflows and max runs inputs are enabled when enableActions is true", () => { + renderSettings(); + const spinbuttons = screen.getAllByRole("spinbutton") as HTMLInputElement[]; + const enabledInputs = spinbuttons.filter((el) => !el.disabled); + expect(enabledInputs.length).toBeGreaterThanOrEqual(2); + }); + + it("workflow runs notification toggle is disabled when Actions is off", () => { + updateConfig({ enableActions: false }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /workflow runs notifications/i }) as HTMLInputElement; + expect(toggle.disabled).toBe(true); + }); + + it("shows explanatory text for workflow runs when Actions is off", () => { + updateConfig({ enableActions: false }); + renderSettings(); + expect(screen.getByText(/disabled — github actions is off/i)).toBeTruthy(); + }); +}); + +describe("Actions toggle — negative cases", () => { + it("toggling off does NOT reset defaultTab when it was not 'actions'", () => { + updateConfig({ defaultTab: "pullRequests" }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + fireEvent.click(toggle); + expect(config.defaultTab).toBe("pullRequests"); + }); + + it("toggling off does NOT reset lastActiveTab when it was not 'actions'", () => { + updateViewState({ lastActiveTab: "pullRequests" }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + fireEvent.click(toggle); + expect(viewState.lastActiveTab).toBe("pullRequests"); + }); + + it("toggling off does NOT change issues or pullRequests notification settings", () => { + updateConfig({ + notifications: { enabled: true, issues: true, pullRequests: true, workflowRuns: true }, + }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + fireEvent.click(toggle); + expect(config.notifications.issues).toBe(true); + expect(config.notifications.pullRequests).toBe(true); + }); +}); + +describe("Actions toggle — default tab dropdown filtering", () => { + it("excludes GitHub Actions option from default tab select when enableActions is false", () => { + updateConfig({ enableActions: false }); + renderSettings(); + const selects = document.querySelectorAll("select"); + let actionsOptionFound = false; + for (const sel of selects) { + for (const opt of sel.options) { + if (opt.value === "actions") { + actionsOptionFound = true; + break; + } + } + } + expect(actionsOptionFound).toBe(false); + }); + + it("includes GitHub Actions option in default tab select when enableActions is true", () => { + renderSettings(); + const selects = document.querySelectorAll("select"); + let actionsOptionFound = false; + for (const sel of selects) { + for (const opt of sel.options) { + if (opt.value === "actions") { + actionsOptionFound = true; + break; + } + } + } + expect(actionsOptionFound).toBe(true); + }); +}); diff --git a/tests/components/shared/CustomTabModal.test.tsx b/tests/components/shared/CustomTabModal.test.tsx index c8a81030..f6549c18 100644 --- a/tests/components/shared/CustomTabModal.test.tsx +++ b/tests/components/shared/CustomTabModal.test.tsx @@ -468,3 +468,55 @@ describe("CustomTabModal — scope accordion", () => { expect(arg.repoScope).toEqual([{ owner: "orgB", name: "repoB1", fullName: "orgB/repoB1" }]); }); }); + +// ── enableActions prop ──────────────────────────────────────────────────────── + +describe("CustomTabModal — enableActions prop", () => { + function renderModalWithActions(enableActions: boolean) { + return render(() => ( + + )); + } + + it("shows Actions option in type select when enableActions is true", () => { + renderModalWithActions(true); + const typeSelect = screen.getByRole("combobox", { name: /type/i }) as HTMLSelectElement; + const values = Array.from(typeSelect.options).map((o) => o.value); + expect(values).toContain("actions"); + }); + + it("hides Actions option in type select when enableActions is false", () => { + renderModalWithActions(false); + const typeSelect = screen.getByRole("combobox", { name: /type/i }) as HTMLSelectElement; + const values = Array.from(typeSelect.options).map((o) => o.value); + expect(values).not.toContain("actions"); + }); + + it("shows Actions option when enableActions is omitted (default visible)", () => { + render(() => ( + + )); + const typeSelect = screen.getByRole("combobox", { name: /type/i }) as HTMLSelectElement; + const values = Array.from(typeSelect.options).map((o) => o.value); + expect(values).toContain("actions"); + }); + + it("still shows Issues and Pull Requests options when enableActions is false", () => { + renderModalWithActions(false); + const typeSelect = screen.getByRole("combobox", { name: /type/i }) as HTMLSelectElement; + const values = Array.from(typeSelect.options).map((o) => o.value); + expect(values).toContain("issues"); + expect(values).toContain("pullRequests"); + }); +}); diff --git a/tests/services/events-poll.test.ts b/tests/services/events-poll.test.ts index 65a59a6e..06caafd2 100644 --- a/tests/services/events-poll.test.ts +++ b/tests/services/events-poll.test.ts @@ -71,6 +71,7 @@ vi.mock("../../src/app/stores/config", () => ({ hotPollInterval: 30, trackedUsers: [], monitoredRepos: [], + enableActions: true, }, })); diff --git a/tests/services/poll-fetchAllData.test.ts b/tests/services/poll-fetchAllData.test.ts index b754c6fe..42d79927 100644 --- a/tests/services/poll-fetchAllData.test.ts +++ b/tests/services/poll-fetchAllData.test.ts @@ -15,6 +15,7 @@ vi.mock("../../src/app/stores/config", () => ({ selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], maxWorkflowsPerRepo: 5, maxRunsPerWorkflow: 3, + enableActions: true, }, })); @@ -98,7 +99,7 @@ describe("fetchAllData — first call", () => { expect(result.errors).toEqual([]); }); - it("calls both fetch functions unconditionally on every call", async () => { + it("calls both fetch functions when enableActions is true", async () => { vi.resetModules(); const { getClient } = await import("../../src/app/services/github"); @@ -108,21 +109,17 @@ describe("fetchAllData — first call", () => { vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); - const { fetchAllData } = await import("../../src/app/services/poll"); await fetchAllData(); - // No notification gate — both data fetches always run - expect(mockOctokit.request).not.toHaveBeenCalled(); expect(fetchIssuesAndPullRequests).toHaveBeenCalledOnce(); expect(fetchWorkflowRuns).toHaveBeenCalledOnce(); - // Second call — still unconditional, no gate check + // Second call — both still run vi.mocked(fetchIssuesAndPullRequests).mockClear(); vi.mocked(fetchWorkflowRuns).mockClear(); await fetchAllData(); - expect(mockOctokit.request).not.toHaveBeenCalled(); expect(fetchIssuesAndPullRequests).toHaveBeenCalledOnce(); expect(fetchWorkflowRuns).toHaveBeenCalledOnce(); }); @@ -284,6 +281,7 @@ describe("fetchAllData — upstream repos and tracked users", () => { trackedUsers: [], maxWorkflowsPerRepo: 5, maxRunsPerWorkflow: 3, + enableActions: true, }, })); @@ -315,6 +313,7 @@ describe("fetchAllData — upstream repos and tracked users", () => { trackedUsers: [], maxWorkflowsPerRepo: 5, maxRunsPerWorkflow: 3, + enableActions: true, }, })); @@ -349,6 +348,7 @@ describe("fetchAllData — upstream repos and tracked users", () => { trackedUsers, maxWorkflowsPerRepo: 5, maxRunsPerWorkflow: 3, + enableActions: true, }, })); @@ -378,6 +378,7 @@ describe("fetchAllData — upstream repos and tracked users", () => { trackedUsers: [], maxWorkflowsPerRepo: 5, maxRunsPerWorkflow: 3, + enableActions: true, }, })); @@ -427,6 +428,7 @@ describe("fetchAllData — upstream repos and tracked users", () => { trackedUsers: [], maxWorkflowsPerRepo: 5, maxRunsPerWorkflow: 3, + enableActions: true, }, })); @@ -568,6 +570,206 @@ describe("fetchAllData — 401 propagation from allSettled", () => { }); +// ── enableActions gate ──────────────────────────────────────────────────────── + +describe("fetchAllData — enableActions gate", () => { + it("skips fetchWorkflowRuns and returns empty workflowRuns when enableActions is false", async () => { + vi.resetModules(); + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], + upstreamRepos: [], + trackedUsers: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + enableActions: false, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + const result = await fetchAllData(); + + expect(fetchWorkflowRuns).not.toHaveBeenCalled(); + expect(result.workflowRuns).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("still calls fetchIssuesAndPullRequests when enableActions is false", async () => { + vi.resetModules(); + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], + upstreamRepos: [], + trackedUsers: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + enableActions: false, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + await fetchAllData(); + + expect(fetchIssuesAndPullRequests).toHaveBeenCalledOnce(); + }); + + it("calls fetchWorkflowRuns when enableActions is true", async () => { + vi.resetModules(); + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }], + upstreamRepos: [], + trackedUsers: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + enableActions: true, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchAllData } = await import("../../src/app/services/poll"); + await fetchAllData(); + + expect(fetchWorkflowRuns).toHaveBeenCalledOnce(); + }); +}); + +describe("fetchTargetedRepoData — enableActions gate", () => { + it("skips fetchWorkflowRuns and returns empty workflowRuns when enableActions is false", async () => { + vi.resetModules(); + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + enableActions: false, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + + const { fetchTargetedRepoData } = await import("../../src/app/services/poll"); + + const repoSummaries = new Map([ + ["octocat/Hello-World", { + repoFullName: "octocat/Hello-World", + eventTypes: new Set(["PushEvent"]), + hasIssueActivity: false, + hasPRActivity: false, + hasWorkflowActivity: true, + latestEventAt: new Date().toISOString(), + }], + ]); + + const result = await fetchTargetedRepoData(repoSummaries); + + expect(fetchWorkflowRuns).not.toHaveBeenCalled(); + expect(result.workflowRuns).toEqual([]); + }); + + it("calls fetchWorkflowRuns for repos with hasWorkflowActivity when enableActions is true", async () => { + vi.resetModules(); + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + enableActions: true, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + vi.mocked(fetchWorkflowRuns).mockResolvedValue(emptyRunResult); + + const { fetchTargetedRepoData } = await import("../../src/app/services/poll"); + + const repoSummaries = new Map([ + ["octocat/Hello-World", { + repoFullName: "octocat/Hello-World", + eventTypes: new Set(["PushEvent"]), + hasIssueActivity: false, + hasPRActivity: false, + hasWorkflowActivity: true, + latestEventAt: new Date().toISOString(), + }], + ]); + + await fetchTargetedRepoData(repoSummaries); + + expect(fetchWorkflowRuns).toHaveBeenCalledOnce(); + const callArgs = vi.mocked(fetchWorkflowRuns).mock.calls[0]; + const passedRepos = callArgs[1] as Array<{ fullName: string }>; + expect(passedRepos[0].fullName).toBe("octocat/Hello-World"); + }); + + it("does not call fetchWorkflowRuns for repos without hasWorkflowActivity even when enableActions is true", async () => { + vi.resetModules(); + + vi.doMock("../../src/app/stores/config", () => ({ + config: { + selectedRepos: [], + maxWorkflowsPerRepo: 5, + maxRunsPerWorkflow: 3, + enableActions: true, + }, + })); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssuesAndPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + vi.mocked(fetchIssuesAndPullRequests).mockResolvedValue(emptyIssuesAndPrsResult); + + const { fetchTargetedRepoData } = await import("../../src/app/services/poll"); + + const repoSummaries = new Map([ + ["octocat/Hello-World", { + repoFullName: "octocat/Hello-World", + eventTypes: new Set(["IssuesEvent"]), + hasIssueActivity: true, + hasPRActivity: false, + hasWorkflowActivity: false, + latestEventAt: new Date().toISOString(), + }], + ]); + + await fetchTargetedRepoData(repoSummaries); + + expect(fetchWorkflowRuns).not.toHaveBeenCalled(); + }); +}); + // ── qa-4: Concurrency verification ──────────────────────────────────────────── describe("fetchAllData — parallel execution", () => { diff --git a/tests/stores/config.test.ts b/tests/stores/config.test.ts index 34a48f00..553b467a 100644 --- a/tests/stores/config.test.ts +++ b/tests/stores/config.test.ts @@ -54,6 +54,13 @@ describe("ConfigSchema", () => { expect(result.rememberLastTab).toBe(true); expect(result.onboardingComplete).toBe(false); expect(result.authMethod).toBe("oauth"); + expect(result.enableActions).toBe(true); + }); + + it("enableActions defaults to true (Actions tab enabled by default)", () => { + expect(ConfigSchema.parse({}).enableActions).toBe(true); + expect(ConfigSchema.parse({ enableActions: false }).enableActions).toBe(false); + expect(ConfigSchema.parse({ enableActions: true }).enableActions).toBe(true); }); it("fills missing fields from defaults when partial input given", () => { From 7b6873e89dfeb3faa4282707ee4e1899550e69e4 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 16:57:44 -0400 Subject: [PATCH 2/7] fix(dashboard): guards pollFetch store write against stale workflow data Strips workflowRuns at the store-write site when enableActions is false, preventing in-flight poll results from leaking stale data. Also adds length guard to SEC-004 effect to skip redundant produce() calls when workflowRuns is already empty. --- src/app/components/dashboard/DashboardPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 3130e089..8489b277 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -257,7 +257,7 @@ async function pollFetch(): Promise { setDashboardData({ issues: data.issues, pullRequests: data.pullRequests, - workflowRuns: data.workflowRuns, + workflowRuns: config.enableActions ? data.workflowRuns : [], loading: false, lastRefreshedAt: now, }); @@ -1073,7 +1073,7 @@ export default function DashboardPage() { // When Actions is disabled, clear stale workflowRuns from the store so memos // computing against empty workflowRuns don't process cached data (SEC-004). createEffect(() => { - if (!config.enableActions) { + if (!config.enableActions && dashboardData.workflowRuns.length > 0) { setDashboardData(produce((d) => { d.workflowRuns = []; })); } }); From c3b3277af31fb4ae368e21140bb533c3f04f34b8 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:00:39 -0400 Subject: [PATCH 3/7] fix(dashboard): filters workflow runs for rebuildHotSets and cache Prevents in-flight poll data from seeding hot run sets or persisting to localStorage cache when enableActions is false at write time. --- src/app/components/dashboard/DashboardPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 8489b277..38eda76e 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -263,14 +263,15 @@ async function pollFetch(): Promise { }); }); } - rebuildHotSets(data); + const filteredRuns = config.enableActions ? data.workflowRuns : []; + rebuildHotSets({ ...data, workflowRuns: filteredRuns }); // Persist for stale-while-revalidate on full page reload. // Errors are transient and not persisted. Deferred to avoid blocking paint. const cachePayload = { _v: CACHE_VERSION, issues: data.issues, pullRequests: data.pullRequests, - workflowRuns: data.workflowRuns, + workflowRuns: filteredRuns, lastRefreshedAt: now.toISOString(), }; setTimeout(() => { From 40e846f3fc22777dabee9e7f3c5b414830ebe78a Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:09:14 -0400 Subject: [PATCH 4/7] refactor(dashboard): extracts isActionsBasedTab helper Deduplicates the actions-tab check predicate from 4 inline sites to one isActionsBasedTab helper. Adds enableActions guard to phase-1 store write and handleTargetedData merge for defense-in-depth. --- .../components/dashboard/DashboardPage.tsx | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 38eda76e..c08e27f6 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -213,7 +213,7 @@ async function pollFetch(): Promise { setDashboardData(produce((state) => { state.issues = data.issues; - state.workflowRuns = data.workflowRuns; + state.workflowRuns = config.enableActions ? data.workflowRuns : []; state.loading = false; state.lastRefreshedAt = now; @@ -357,14 +357,15 @@ function handleTargetedData(data: DashboardData, affectedRepos: string[]): void ), ...data.pullRequests, ]; - const newRunIds = new Set(data.workflowRuns.map(r => r.id)); - state.workflowRuns = [ + const targetedRuns = config.enableActions ? data.workflowRuns : []; + const newRunIds = new Set(targetedRuns.map(r => r.id)); + state.workflowRuns = config.enableActions ? [ ...state.workflowRuns.filter(r => !affectedSet.has(r.repoFullName.toLowerCase()) || !newRunIds.has(r.id) ), - ...data.workflowRuns, - ]; + ...targetedRuns, + ] : []; })); }); @@ -529,12 +530,15 @@ export default function DashboardPage() { }; } + function isActionsBasedTab(tab: TabId): boolean { + return tab === "actions" || (!isBuiltinTab(tab) && config.customTabs.some((t) => t.id === tab && t.baseType === "actions")); + } + function resolveInitialTab(): TabId { const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab; if (tab === "tracked" && !config.enableTracking) return "issues"; if (tab === "jiraAssigned" && !config.jira?.enabled) return "issues"; - if (tab === "actions" && !config.enableActions) return "issues"; - if (!config.enableActions && !isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions") return "issues"; + if (!config.enableActions && isActionsBasedTab(tab)) return "issues"; // Validate custom tab still exists; fall back to "issues" if stale if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return "issues"; return tab; @@ -545,10 +549,7 @@ export default function DashboardPage() { function handleTabChange(tab: TabId) { // Reject invalid tab IDs to prevent persisting stale state if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return; - if (!config.enableActions) { - if (tab === "actions") return; - if (!isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions") return; - } + if (!config.enableActions && isActionsBasedTab(tab)) return; setActiveTab(tab); updateViewState({ lastActiveTab: tab }); } @@ -578,10 +579,8 @@ export default function DashboardPage() { // Redirect away from Actions tab (or actions-based custom tab) when Actions is disabled createEffect(() => { - if (!config.enableActions) { - const tab = activeTab(); - const isActionsTab = tab === "actions" || (!isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions"); - if (isActionsTab) handleTabChange("issues"); + if (!config.enableActions && isActionsBasedTab(activeTab())) { + handleTabChange("issues"); } }); From 71900f25aa6cbab06000b13b31bb972f3578c304 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:28:52 -0400 Subject: [PATCH 5/7] fix(settings): renames toggle to Show Actions tab Clarifies the toggle controls dashboard display, not GitHub Actions itself. Updated description mentions both API savings and dashboard simplification. --- src/app/components/settings/SettingsPage.tsx | 6 +++--- .../settings/actions-toggle.test.tsx | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 28e7bcc1..b6c7e2ba 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -609,14 +609,14 @@ export default function SettingsPage() { {/* Section 5: GitHub Actions */}
{ const val = e.currentTarget.checked; diff --git a/tests/components/settings/actions-toggle.test.tsx b/tests/components/settings/actions-toggle.test.tsx index ea96baa2..3f7596d4 100644 --- a/tests/components/settings/actions-toggle.test.tsx +++ b/tests/components/settings/actions-toggle.test.tsx @@ -144,13 +144,13 @@ afterEach(() => { describe("Actions enable toggle", () => { it("renders checked by default (enableActions defaults to true)", () => { renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }) as HTMLInputElement; + const toggle = screen.getByRole("switch", { name: /show actions tab/i }) as HTMLInputElement; expect(toggle.checked).toBe(true); }); it("toggling off sets enableActions to false", () => { renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); fireEvent.click(toggle); expect(config.enableActions).toBe(false); }); @@ -158,7 +158,7 @@ describe("Actions enable toggle", () => { it("toggling off resets defaultTab to 'issues' when it was 'actions'", () => { updateConfig({ defaultTab: "actions" }); renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); fireEvent.click(toggle); expect(config.defaultTab).toBe("issues"); }); @@ -166,21 +166,21 @@ describe("Actions enable toggle", () => { it("toggling off resets lastActiveTab to 'issues' when it was 'actions'", () => { updateViewState({ lastActiveTab: "actions" }); renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); fireEvent.click(toggle); expect(viewState.lastActiveTab).toBe("issues"); }); it("toggling off suppresses workflowRuns notification", () => { renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); fireEvent.click(toggle); expect(config.notifications.workflowRuns).toBe(false); }); it("re-enable does NOT auto-restore workflowRuns notification", () => { renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); // Disable fireEvent.click(toggle); expect(config.notifications.workflowRuns).toBe(false); @@ -225,7 +225,7 @@ describe("Actions toggle — negative cases", () => { it("toggling off does NOT reset defaultTab when it was not 'actions'", () => { updateConfig({ defaultTab: "pullRequests" }); renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); fireEvent.click(toggle); expect(config.defaultTab).toBe("pullRequests"); }); @@ -233,7 +233,7 @@ describe("Actions toggle — negative cases", () => { it("toggling off does NOT reset lastActiveTab when it was not 'actions'", () => { updateViewState({ lastActiveTab: "pullRequests" }); renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); fireEvent.click(toggle); expect(viewState.lastActiveTab).toBe("pullRequests"); }); @@ -243,7 +243,7 @@ describe("Actions toggle — negative cases", () => { notifications: { enabled: true, issues: true, pullRequests: true, workflowRuns: true }, }); renderSettings(); - const toggle = screen.getByRole("switch", { name: /enable github actions/i }); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); fireEvent.click(toggle); expect(config.notifications.issues).toBe(true); expect(config.notifications.pullRequests).toBe(true); From 895ce3498580a252ba52eef95f981857f14b7350 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sun, 3 May 2026 17:41:47 -0400 Subject: [PATCH 6/7] chore: removes internal review IDs from comments SEC-004/SEC-010 references are swarm audit trail identifiers that have no meaning in production code. --- mcp/src/tools.ts | 1 - src/app/components/dashboard/DashboardPage.tsx | 3 +-- src/app/lib/mcp-relay.ts | 2 -- src/shared/types.ts | 2 +- 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts index efc8b675..eb2f7ec9 100644 --- a/mcp/src/tools.ts +++ b/mcp/src/tools.ts @@ -189,7 +189,6 @@ export function registerTools(server: McpServer, dataSource: DataSource): void { async (args) => { const { repo } = args as { repo?: string }; try { - // SEC-010: check config before calling getFailingActions to surface disabled state const cachedConfig: CachedConfig | null = await dataSource.getConfig(); if (cachedConfig?.enableActions === false) { const text = "GitHub Actions monitoring is disabled in the dashboard. Enable it in Settings to track workflow runs." + stalenessLine(); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index c08e27f6..e5eb07d9 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1070,8 +1070,7 @@ export default function DashboardPage() { { defer: true } )); - // When Actions is disabled, clear stale workflowRuns from the store so memos - // computing against empty workflowRuns don't process cached data (SEC-004). + // When Actions is disabled, clear stale workflowRuns restored from cache. createEffect(() => { if (!config.enableActions && dashboardData.workflowRuns.length > 0) { setDashboardData(produce((d) => { d.workflowRuns = []; })); diff --git a/src/app/lib/mcp-relay.ts b/src/app/lib/mcp-relay.ts index ea4bc127..3311625d 100644 --- a/src/app/lib/mcp-relay.ts +++ b/src/app/lib/mcp-relay.ts @@ -126,7 +126,6 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { const result = { openPRCount: openPRs.length, openIssueCount: s.issues.filter((i) => i.state === "OPEN").length, - // SEC-010: null when Actions monitoring is disabled so AI clients don't interpret 0 as "all clear" failingRunCount: s.enableActions ? s.workflowRuns.filter((r) => r.conclusion === "failure" || r.conclusion === "timed_out").length : null, @@ -177,7 +176,6 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { } case METHODS.GET_FAILING_ACTIONS: { - // SEC-010: surface disabled state rather than returning empty array (false "all clear") if (!snapshot!.enableActions) { sendResponse(ws, { jsonrpc: "2.0", diff --git a/src/shared/types.ts b/src/shared/types.ts index 85016f68..70c1d675 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -114,7 +114,7 @@ export interface RateLimitInfo { export interface DashboardSummary { openPRCount: number; openIssueCount: number; - /** null when Actions monitoring is disabled (SEC-010: avoids false "all clear") */ + /** null when Actions monitoring is disabled */ failingRunCount: number | null; actionsMonitoringDisabled?: boolean; needsReviewCount: number; From 2b43fed5ed413159a2e2abe2cc949f3409a16b54 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Mon, 4 May 2026 10:29:52 -0400 Subject: [PATCH 7/7] fix: address PR review findings for optional Actions tab - Add enableActions to GET_CONFIG relay response - Handle disabled sentinel in WebSocketDataSource.getFailingActions - Add enableActions prop to PersonalSummaryStrip - Set hasPRActivity for PushEvent in parseRepoEvents - Extract isActionsBasedTab to shared schemas.ts - Remove unused actionsMonitoringDisabled from DashboardSummary - Add enableActions gating tests for MCP data-source and tools - Add custom tab reset and dropdown filtering tests - Update USER_GUIDE.md with Actions toggle documentation --- docs/USER_GUIDE.md | 9 ++- mcp/src/data-source.ts | 10 ++- mcp/tests/data-source.test.ts | 71 +++++++++++++++++-- mcp/tests/integration.test.ts | 3 +- mcp/tests/resources.test.ts | 1 + mcp/tests/tools.test.ts | 51 +++++++++++++ .../components/dashboard/DashboardPage.tsx | 13 ++-- .../dashboard/PersonalSummaryStrip.tsx | 3 +- src/app/components/settings/SettingsPage.tsx | 8 +-- src/app/lib/mcp-relay.ts | 2 +- src/app/services/events.ts | 1 + src/app/stores/config.ts | 2 +- src/shared/schemas.ts | 4 ++ src/shared/types.ts | 1 - tests/app/lib/mcp-relay.test.ts | 9 +-- .../dashboard/PersonalSummaryStrip.test.tsx | 8 +++ .../settings/actions-toggle.test.tsx | 48 +++++++++++++ tests/services/events.test.ts | 3 +- 18 files changed, 213 insertions(+), 34 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3f2520d5..5e525c3e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -240,6 +240,8 @@ Sort by: Repo, Title, Author, Checks, Review, Size, Created, Updated (default: U ## Actions Tab +The Actions tab is enabled by default. You can disable it in **Settings > GitHub Actions > Show Actions tab**. Disabling it hides the tab, skips all workflow run API calls (saving REST API rate limit budget), suppresses workflow run notifications, and hides the "actions running" count from the summary strip. Custom tabs based on Actions are also hidden when disabled. Re-enabling restores the tab immediately; workflow run data refreshes on the next poll cycle. + ### Workflow Grouping Workflow runs are grouped first by repository, then by workflow name. Each workflow group shows its most recent runs up to the configured limit (default: 3 runs per workflow, up to 5 workflows per repo). @@ -575,12 +577,13 @@ Settings are saved automatically to `localStorage` and persist across sessions. |---------|---------|-------------| | Refresh interval | 5 minutes | How often to poll GitHub for new data. Options: 1, 2, 5, 10, 15, 30 minutes, or Off. | | CI status refresh (hot poll interval) | 30 seconds | How often to re-check in-flight CI checks and workflow runs. Range: 10–120 seconds. | -| Max workflows per repo | 5 | Number of active workflows to track per repository. Range: 1–20. | -| Max runs per workflow | 3 | Number of recent runs to show per workflow. Range: 1–10. | +| Show Actions tab | On | Show the Actions tab and track workflow runs. Disable to skip all workflow run API calls and simplify the dashboard. | +| Max workflows per repo | 5 | Number of active workflows to track per repository. Range: 1–20. Disabled when Actions is off. | +| Max runs per workflow | 3 | Number of recent runs to show per workflow. Range: 1–10. Disabled when Actions is off. | | Notifications enabled | Off | Master toggle for browser push notifications. | | Notify: Issues | On | Notify when new issues open (requires notifications enabled). | | Notify: Pull Requests | On | Notify when PRs are opened or updated (requires notifications enabled). | -| Notify: Workflow Runs | On | Notify when workflow runs complete (requires notifications enabled). | +| Notify: Workflow Runs | On | Notify when workflow runs complete (requires notifications enabled). Disabled when Actions is off. | | Theme | Auto | UI color theme. Auto follows system dark/light preference (Corporate for light, Dim for dark). | | View density | Comfortable | Spacing between list items. Options: Comfortable, Compact. | | Items per page | 25 | Number of items per page in each tab. Options: 10, 25, 50, 100. | diff --git a/mcp/src/data-source.ts b/mcp/src/data-source.ts index 9bdb471b..4f3fc649 100644 --- a/mcp/src/data-source.ts +++ b/mcp/src/data-source.ts @@ -455,7 +455,7 @@ export class OctokitDataSource implements DataSource { const actionsEnabled = _cachedConfig?.enableActions !== false; if (repos.length === 0) { - return { openPRCount: 0, openIssueCount: 0, failingRunCount: actionsEnabled ? 0 : null, needsReviewCount: 0, approvedUnmergedCount: 0, actionsMonitoringDisabled: !actionsEnabled }; + return { openPRCount: 0, openIssueCount: 0, failingRunCount: actionsEnabled ? 0 : null, needsReviewCount: 0, approvedUnmergedCount: 0 }; } const repoFilter = repos.map((r) => `repo:${r.owner}/${r.name}`).join("+"); @@ -507,7 +507,7 @@ export class OctokitDataSource implements DataSource { } } - return { openPRCount, openIssueCount, failingRunCount: finalFailingRunCount, needsReviewCount, approvedUnmergedCount, actionsMonitoringDisabled: !actionsEnabled }; + return { openPRCount, openIssueCount, failingRunCount: finalFailingRunCount, needsReviewCount, approvedUnmergedCount }; } async getConfig(): Promise { @@ -536,7 +536,11 @@ export class WebSocketDataSource implements DataSource { } async getFailingActions(repo?: string): Promise { - return sendRelayRequest(METHODS.GET_FAILING_ACTIONS, { repo }) as Promise; + const result = await sendRelayRequest(METHODS.GET_FAILING_ACTIONS, { repo }); + if (result !== null && typeof result === "object" && !Array.isArray(result) && "disabled" in (result as Record)) { + return []; + } + return result as WorkflowRun[]; } async getPRDetails(repo: string, number: number): Promise { diff --git a/mcp/tests/data-source.test.ts b/mcp/tests/data-source.test.ts index e72a33d6..8b1729cf 100644 --- a/mcp/tests/data-source.test.ts +++ b/mcp/tests/data-source.test.ts @@ -117,13 +117,14 @@ describe("OctokitDataSource", () => { trackedUsers: [], upstreamRepos: [], monitoredRepos: [], + enableActions: true, }); }); afterEach(() => { vi.restoreAllMocks(); // Clear cached config - setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] }); + setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true }); }); describe("getOpenPRs", () => { @@ -189,7 +190,7 @@ describe("OctokitDataSource", () => { it("accepts explicit repo parameter and skips cached config", async () => { // Clear cached config to verify explicit param works without it - setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] }); + setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true }); const responses = new Map([ ["GET /user", makeUserResponse()], @@ -208,7 +209,7 @@ describe("OctokitDataSource", () => { it("returns empty array when config has no repos and no explicit repo", async () => { // setCachedConfig with empty selectedRepos → resolveRepos returns [] - setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] }); + setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true }); const responses = new Map([["GET /user", makeUserResponse()]]); const octokit = makeMockOctokit(responses); const ds = new OctokitDataSource(octokit); @@ -433,11 +434,26 @@ describe("OctokitDataSource", () => { }); it("returns empty array when config has no repos and no explicit repo", async () => { - setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] }); + setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true }); const ds = new OctokitDataSource({ request: vi.fn() }); const runs = await ds.getFailingActions(); expect(runs).toEqual([]); }); + + it("returns empty array when enableActions is false", async () => { + setCachedConfig({ + selectedRepos: [{ owner: "owner", name: "repo", fullName: "owner/repo" }], + trackedUsers: [], + upstreamRepos: [], + monitoredRepos: [], + enableActions: false, + }); + const requestMock = vi.fn(); + const ds = new OctokitDataSource({ request: requestMock }); + const runs = await ds.getFailingActions(); + expect(runs).toEqual([]); + expect(requestMock).not.toHaveBeenCalled(); + }); }); describe("getPRDetails", () => { @@ -502,7 +518,7 @@ describe("OctokitDataSource", () => { describe("getDashboardSummary", () => { it("returns zero counts when no repos are configured", async () => { - setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] }); + setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true }); const octokit = makeMockOctokit(new Map([["GET /user", makeUserResponse()]])); const ds = new OctokitDataSource(octokit); const summary = await ds.getDashboardSummary("involves_me"); @@ -514,6 +530,16 @@ describe("OctokitDataSource", () => { expect(summary.approvedUnmergedCount).toBe(0); }); + it("returns failingRunCount=null in early return when no repos and enableActions is false", async () => { + setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: false }); + const octokit = makeMockOctokit(new Map([["GET /user", makeUserResponse()]])); + const ds = new OctokitDataSource(octokit); + const summary = await ds.getDashboardSummary("involves_me"); + + expect(summary.failingRunCount).toBeNull(); + expect(summary.openPRCount).toBe(0); + }); + it("constructs involves_me query with user login", async () => { const requestMock = vi.fn().mockImplementation(async (route: string) => { if (route === "GET /user") return { data: { login: "testuser" }, headers: {} }; @@ -556,6 +582,30 @@ describe("OctokitDataSource", () => { expect(prCall).toBeDefined(); expect(prCall![1].q).not.toContain("involves:"); }); + + it("returns failingRunCount=null and skips actions API when enableActions is false", async () => { + setCachedConfig({ + selectedRepos: [{ owner: "owner", name: "repo", fullName: "owner/repo" }], + trackedUsers: [], + upstreamRepos: [], + monitoredRepos: [], + enableActions: false, + }); + const requestMock = vi.fn().mockImplementation(async (route: string) => { + if (route === "GET /user") return { data: { login: "testuser" }, headers: {} }; + if (route === "GET /search/issues") return { data: { items: [], total_count: 0 }, headers: {} }; + throw new Error(`Unexpected: ${route}`); + }); + + const ds = new OctokitDataSource({ request: requestMock }); + const summary = await ds.getDashboardSummary("involves_me"); + + expect(summary.failingRunCount).toBeNull(); + const actionsCalls = requestMock.mock.calls.filter( + ([route]: [string]) => route === "GET /repos/{owner}/{repo}/actions/runs" + ); + expect(actionsCalls).toHaveLength(0); + }); }); describe("getConfig", () => { @@ -565,6 +615,7 @@ describe("OctokitDataSource", () => { trackedUsers: [], upstreamRepos: [], monitoredRepos: [], + enableActions: true, }; setCachedConfig(config); const ds = new OctokitDataSource({ request: vi.fn() }); @@ -641,6 +692,7 @@ describe("CompositeDataSource", () => { trackedUsers: [], upstreamRepos: [], monitoredRepos: [], + enableActions: true, }); }); @@ -755,4 +807,13 @@ describe("CompositeDataSource", () => { expect(octokitDs.getRateLimit).toHaveBeenCalled(); expect(_mockSendRequest).not.toHaveBeenCalled(); }); + + it("WebSocketDataSource.getFailingActions returns empty array for disabled sentinel", async () => { + _mockIsConnected = true; + _mockSendRequest = vi.fn().mockResolvedValue({ disabled: true, message: "Actions monitoring is disabled" }); + + const wsDs = new WebSocketDataSource(); + const result = await wsDs.getFailingActions(); + expect(result).toEqual([]); + }); }); diff --git a/mcp/tests/integration.test.ts b/mcp/tests/integration.test.ts index 7c0c7254..e268962c 100644 --- a/mcp/tests/integration.test.ts +++ b/mcp/tests/integration.test.ts @@ -439,6 +439,7 @@ describe("Integration: Edge cases (with server)", () => { trackedUsers: [], upstreamRepos: [], monitoredRepos: [], + enableActions: true, }); if (!wss) throw new Error("Server not started"); @@ -461,7 +462,7 @@ describe("Integration: Edge cases (with server)", () => { getRateLimit: vi.fn(), getConfig: vi.fn().mockResolvedValue({ selectedRepos: [{ owner: "acme", name: "app", fullName: "acme/app" }], - trackedUsers: [], upstreamRepos: [], monitoredRepos: [], + trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true, }), getRepos: vi.fn().mockResolvedValue([{ owner: "acme", name: "app", fullName: "acme/app" }]), }; diff --git a/mcp/tests/resources.test.ts b/mcp/tests/resources.test.ts index f8d36095..a6eb7de0 100644 --- a/mcp/tests/resources.test.ts +++ b/mcp/tests/resources.test.ts @@ -58,6 +58,7 @@ function makeConfig(overrides: Partial = {}): CachedConfig { trackedUsers: [], upstreamRepos: [], monitoredRepos: [], + enableActions: true, ...overrides, }; } diff --git a/mcp/tests/tools.test.ts b/mcp/tests/tools.test.ts index 6c210f3f..2bba516c 100644 --- a/mcp/tests/tools.test.ts +++ b/mcp/tests/tools.test.ts @@ -125,6 +125,22 @@ describe("get_dashboard_summary", () => { // isRelayConnected is mocked to return false, so staleness note should be present expect(result.content[0].text).toContain("data via GitHub API"); }); + + it("shows actions disabled indicator when failingRunCount is null", async () => { + const disabledSummary: DashboardSummary = { + openPRCount: 2, + openIssueCount: 1, + failingRunCount: null, + needsReviewCount: 0, + approvedUnmergedCount: 0, + }; + vi.mocked(ds.getDashboardSummary).mockResolvedValueOnce(disabledSummary); + const result = await callTool(server, "get_dashboard_summary"); + expect(result.isError).toBeFalsy(); + const text = result.content[0].text; + expect(text).toContain("Actions monitoring disabled"); + expect(text).not.toMatch(/Failing CI Runs:\s+\d/); + }); }); describe("get_open_prs", () => { @@ -315,6 +331,41 @@ describe("get_failing_actions", () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Error fetching workflow runs"); }); + + it("returns disabled message when enableActions is false", async () => { + vi.mocked(ds.getConfig).mockResolvedValueOnce({ + selectedRepos: [{ owner: "owner", name: "repo", fullName: "owner/repo" }], + trackedUsers: [], + upstreamRepos: [], + monitoredRepos: [], + enableActions: false, + }); + const result = await callTool(server, "get_failing_actions"); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("Actions monitoring is disabled"); + expect(ds.getFailingActions).not.toHaveBeenCalled(); + }); + + it("proceeds normally when enableActions is true", async () => { + vi.mocked(ds.getConfig).mockResolvedValueOnce({ + selectedRepos: [{ owner: "owner", name: "repo", fullName: "owner/repo" }], + trackedUsers: [], + upstreamRepos: [], + monitoredRepos: [], + enableActions: true, + }); + const result = await callTool(server, "get_failing_actions"); + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain("No failing or in-progress workflow runs found"); + expect(ds.getFailingActions).toHaveBeenCalled(); + }); + + it("proceeds normally when getConfig returns null", async () => { + vi.mocked(ds.getConfig).mockResolvedValueOnce(null); + const result = await callTool(server, "get_failing_actions"); + expect(result.isError).toBeFalsy(); + expect(ds.getFailingActions).toHaveBeenCalled(); + }); }); describe("get_pr_details", () => { diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index e5eb07d9..32c7e4ff 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -8,7 +8,7 @@ import IssuesTab from "./IssuesTab"; import PullRequestsTab from "./PullRequestsTab"; import TrackedTab from "./TrackedTab"; import PersonalSummaryStrip from "./PersonalSummaryStrip"; -import { config, setConfig, getCustomTab, isBuiltinTab, updateJiraConfig, type TrackedUser } from "../../stores/config"; +import { config, setConfig, getCustomTab, isBuiltinTab, isActionsBasedTab, updateJiraConfig, type TrackedUser } from "../../stores/config"; import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState, untrackJiraItem, setTabFilter, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; @@ -530,15 +530,11 @@ export default function DashboardPage() { }; } - function isActionsBasedTab(tab: TabId): boolean { - return tab === "actions" || (!isBuiltinTab(tab) && config.customTabs.some((t) => t.id === tab && t.baseType === "actions")); - } - function resolveInitialTab(): TabId { const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab; if (tab === "tracked" && !config.enableTracking) return "issues"; if (tab === "jiraAssigned" && !config.jira?.enabled) return "issues"; - if (!config.enableActions && isActionsBasedTab(tab)) return "issues"; + if (!config.enableActions && isActionsBasedTab(tab, config.customTabs)) return "issues"; // Validate custom tab still exists; fall back to "issues" if stale if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return "issues"; return tab; @@ -549,7 +545,7 @@ export default function DashboardPage() { function handleTabChange(tab: TabId) { // Reject invalid tab IDs to prevent persisting stale state if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return; - if (!config.enableActions && isActionsBasedTab(tab)) return; + if (!config.enableActions && isActionsBasedTab(tab, config.customTabs)) return; setActiveTab(tab); updateViewState({ lastActiveTab: tab }); } @@ -579,7 +575,7 @@ export default function DashboardPage() { // Redirect away from Actions tab (or actions-based custom tab) when Actions is disabled createEffect(() => { - if (!config.enableActions && isActionsBasedTab(activeTab())) { + if (!config.enableActions && isActionsBasedTab(activeTab(), config.customTabs)) { handleTabChange("issues"); } }); @@ -1110,6 +1106,7 @@ export default function DashboardPage() { pullRequests={visiblePullRequests()} workflowRuns={visibleWorkflowRuns()} userLogin={userLogin()} + enableActions={config.enableActions} onTabChange={handleTabChange} /> void; } @@ -142,7 +143,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { })); }, }); - if (running > 0) items.push({ + if (running > 0 && props.enableActions !== false) items.push({ label: running === 1 ? "action running" : "actions running", count: running, tab: "actions", diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index b6c7e2ba..fabfc7b0 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -2,7 +2,7 @@ import { createSignal, createMemo, Show, For, onCleanup, onMount } from "solid-j import * as Sentry from "@sentry/solid"; import { getRelayStatus } from "../../lib/mcp-relay"; import { useNavigate } from "@solidjs/router"; -import { config, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo } from "../../stores/config"; +import { config, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo, isActionsBasedTab } from "../../stores/config"; import type { Config } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; import { clearAuth, jiraAuth, setJiraAuth, clearJiraConfigFull, isJiraAuthenticated } from "../../stores/auth"; @@ -620,10 +620,8 @@ export default function SettingsPage() { checked={config.enableActions} onChange={(e) => { const val = e.currentTarget.checked; - const isActionsCustomTab = (id: string) => - config.customTabs.some((t) => t.id === id && t.baseType === "actions"); - const needsDefaultReset = !val && (config.defaultTab === "actions" || isActionsCustomTab(config.defaultTab)); - const needsLastTabReset = !val && (viewState.lastActiveTab === "actions" || isActionsCustomTab(viewState.lastActiveTab)); + const needsDefaultReset = !val && isActionsBasedTab(config.defaultTab, config.customTabs); + const needsLastTabReset = !val && isActionsBasedTab(viewState.lastActiveTab, config.customTabs); saveWithFeedback({ enableActions: val, ...(needsDefaultReset ? { defaultTab: "issues" as const } : {}), diff --git a/src/app/lib/mcp-relay.ts b/src/app/lib/mcp-relay.ts index 3311625d..274b176a 100644 --- a/src/app/lib/mcp-relay.ts +++ b/src/app/lib/mcp-relay.ts @@ -129,7 +129,6 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { failingRunCount: s.enableActions ? s.workflowRuns.filter((r) => r.conclusion === "failure" || r.conclusion === "timed_out").length : null, - actionsMonitoringDisabled: !s.enableActions, needsReviewCount: openPRs.filter((p) => p.reviewDecision === "REVIEW_REQUIRED").length, approvedUnmergedCount: openPRs.filter((p) => p.reviewDecision === "APPROVED").length, }; @@ -251,6 +250,7 @@ function handleRequest(ws: WebSocket, req: JsonRpcRequest): void { trackedUsers: config.trackedUsers, upstreamRepos: config.upstreamRepos, monitoredRepos: config.monitoredRepos, + enableActions: config.enableActions, }, }); break; diff --git a/src/app/services/events.ts b/src/app/services/events.ts index e7489d4f..99104be3 100644 --- a/src/app/services/events.ts +++ b/src/app/services/events.ts @@ -161,6 +161,7 @@ export function parseRepoEvents( summary.hasPRActivity = true; } if (event.type === "PushEvent") { + summary.hasPRActivity = true; summary.hasWorkflowActivity = true; } diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts index e30ef2a3..fed62eb1 100644 --- a/src/app/stores/config.ts +++ b/src/app/stores/config.ts @@ -9,7 +9,7 @@ import { z } from "zod"; // ── Re-exports from shared/schemas (backward compat for existing importers) ─── export { ConfigSchema, RepoRefSchema, TrackedUserSchema, THEME_OPTIONS, - CustomTabSchema, BUILTIN_TAB_IDS, isBuiltinTab, + CustomTabSchema, BUILTIN_TAB_IDS, isBuiltinTab, isActionsBasedTab, type Config, type TrackedUser, type ThemeId, type CustomTab, type BuiltinTabId, type JiraConfig, } from "../../shared/schemas"; diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index e629f53a..96069d9f 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -50,6 +50,10 @@ export function isBuiltinTab(id: string): id is BuiltinTabId { return (BUILTIN_TAB_IDS as readonly string[]).includes(id); } +export function isActionsBasedTab(id: string, customTabs: readonly CustomTab[]): boolean { + return id === "actions" || (!isBuiltinTab(id) && customTabs.some((t) => t.id === id && t.baseType === "actions")); +} + export const JiraAuthMethodSchema = z.enum(["oauth", "token"]).default("oauth"); export const JiraCustomFieldSchema = z.object({ diff --git a/src/shared/types.ts b/src/shared/types.ts index 70c1d675..027d58ec 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -116,7 +116,6 @@ export interface DashboardSummary { openIssueCount: number; /** null when Actions monitoring is disabled */ failingRunCount: number | null; - actionsMonitoringDisabled?: boolean; needsReviewCount: number; approvedUnmergedCount: number; } diff --git a/tests/app/lib/mcp-relay.test.ts b/tests/app/lib/mcp-relay.test.ts index b969c009..13b948c9 100644 --- a/tests/app/lib/mcp-relay.test.ts +++ b/tests/app/lib/mcp-relay.test.ts @@ -14,6 +14,7 @@ const mockConfigStore = { trackedUsers: [], upstreamRepos: [], monitoredRepos: [], + enableActions: true, }; vi.mock("../../../src/app/stores/config", () => ({ @@ -299,7 +300,7 @@ describe("GET_DASHBOARD_SUMMARY handler", () => { expect(parsed.result.approvedUnmergedCount).toBe(1); }); - it("returns failingRunCount=null and actionsMonitoringDisabled=true when enableActions is false", () => { + it("returns failingRunCount=null when enableActions is false", () => { const issues = [makeIssue({ state: "OPEN" })]; const runs = [makeWorkflowRun({ conclusion: "failure" })]; mod.updateRelaySnapshot({ issues, pullRequests: [], workflowRuns: runs, enableActions: false, lastUpdatedAt: Date.now() }); @@ -311,9 +312,8 @@ describe("GET_DASHBOARD_SUMMARY handler", () => { ws._triggerMessage(JSON.stringify({ jsonrpc: "2.0", id: 11, method: "get_dashboard_summary", params: { scope: "involves_me" } })); const response = responses.find((r) => (JSON.parse(r) as { id?: number }).id === 11); - const parsed = JSON.parse(response!) as { result: { failingRunCount: number | null; actionsMonitoringDisabled?: boolean; openIssueCount: number } }; + const parsed = JSON.parse(response!) as { result: { failingRunCount: number | null; openIssueCount: number } }; expect(parsed.result.failingRunCount).toBeNull(); - expect(parsed.result.actionsMonitoringDisabled).toBe(true); expect(parsed.result.openIssueCount).toBe(1); }); }); @@ -642,10 +642,11 @@ describe("GET_CONFIG handler", () => { ws._triggerOpen(); ws._triggerMessage(JSON.stringify({ jsonrpc: "2.0", id: 70, method: "get_config", params: {} })); const parsed = JSON.parse(responses.find((r) => (JSON.parse(r) as { id?: number }).id === 70)!) as { - result: { selectedRepos: unknown[]; trackedUsers: unknown[]; upstreamRepos: unknown[]; monitoredRepos: unknown[] }; + result: { selectedRepos: unknown[]; trackedUsers: unknown[]; upstreamRepos: unknown[]; monitoredRepos: unknown[]; enableActions: boolean }; }; expect(parsed.result.selectedRepos).toBeDefined(); expect(parsed.result.trackedUsers).toBeDefined(); + expect(parsed.result.enableActions).toBe(true); }); }); diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index a469faf1..2e2b5236 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -21,6 +21,7 @@ function renderStrip(opts: { pullRequests?: PullRequest[]; workflowRuns?: WorkflowRun[]; userLogin?: string; + enableActions?: boolean; onTabChange?: (tab: import("../../../src/app/components/layout/TabBar").TabId) => void; }) { const onTabChange = opts.onTabChange ?? vi.fn(); @@ -30,6 +31,7 @@ function renderStrip(opts: { pullRequests={opts.pullRequests ?? []} workflowRuns={opts.workflowRuns ?? []} userLogin={opts.userLogin ?? "me"} + enableActions={opts.enableActions} onTabChange={onTabChange} /> )); @@ -444,6 +446,12 @@ describe("PersonalSummaryStrip — label context", () => { renderStrip({ workflowRuns: runs }); screen.getByText(/action running/); }); + + it("hides 'action running' when enableActions is false", () => { + const runs = [makeWorkflowRun({ status: "in_progress" })]; + renderStrip({ workflowRuns: runs, enableActions: false }); + expect(screen.queryByText(/action running/)).toBeNull(); + }); }); describe("PersonalSummaryStrip — cor-2: excludes self-authored PRs from awaiting review", () => { diff --git a/tests/components/settings/actions-toggle.test.tsx b/tests/components/settings/actions-toggle.test.tsx index 3f7596d4..cefaae2c 100644 --- a/tests/components/settings/actions-toggle.test.tsx +++ b/tests/components/settings/actions-toggle.test.tsx @@ -171,6 +171,32 @@ describe("Actions enable toggle", () => { expect(viewState.lastActiveTab).toBe("issues"); }); + it("toggling off resets defaultTab to 'issues' when it is an actions-based custom tab", () => { + updateConfig({ + customTabs: [ + { id: "my-actions", name: "My Actions", baseType: "actions", orgScope: [], repoScope: [], filterPreset: {}, exclusive: false }, + ], + defaultTab: "my-actions", + }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); + fireEvent.click(toggle); + expect(config.defaultTab).toBe("issues"); + }); + + it("toggling off resets lastActiveTab to 'issues' when it is an actions-based custom tab", () => { + updateConfig({ + customTabs: [ + { id: "my-actions", name: "My Actions", baseType: "actions", orgScope: [], repoScope: [], filterPreset: {}, exclusive: false }, + ], + }); + updateViewState({ lastActiveTab: "my-actions" }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /show actions tab/i }); + fireEvent.click(toggle); + expect(viewState.lastActiveTab).toBe("issues"); + }); + it("toggling off suppresses workflowRuns notification", () => { renderSettings(); const toggle = screen.getByRole("switch", { name: /show actions tab/i }); @@ -281,4 +307,26 @@ describe("Actions toggle — default tab dropdown filtering", () => { } expect(actionsOptionFound).toBe(true); }); + + it("excludes actions-based custom tabs from default tab select when enableActions is false", () => { + updateConfig({ + enableActions: false, + customTabs: [ + { id: "my-actions", name: "My Actions", baseType: "actions", orgScope: [], repoScope: [], filterPreset: {}, exclusive: false }, + { id: "my-issues", name: "My Issues", baseType: "issues", orgScope: [], repoScope: [], filterPreset: {}, exclusive: false }, + ], + }); + renderSettings(); + const selects = document.querySelectorAll("select"); + let actionsCustomFound = false; + let issuesCustomFound = false; + for (const sel of selects) { + for (const opt of sel.options) { + if (opt.value === "my-actions") actionsCustomFound = true; + if (opt.value === "my-issues") issuesCustomFound = true; + } + } + expect(actionsCustomFound).toBe(false); + expect(issuesCustomFound).toBe(true); + }); }); diff --git a/tests/services/events.test.ts b/tests/services/events.test.ts index 2e673711..4dc47569 100644 --- a/tests/services/events.test.ts +++ b/tests/services/events.test.ts @@ -257,11 +257,12 @@ describe("parseRepoEvents", () => { expect(summary.hasIssueActivity).toBe(false); }); - it("sets hasWorkflowActivity for PushEvent", () => { + it("sets hasPRActivity and hasWorkflowActivity for PushEvent", () => { const events = [makeEvent({ type: "PushEvent", repoName: "owner/repo" })]; const result = parseRepoEvents(events, new Set(["owner/repo"])); expect(result.get("owner/repo")!.hasWorkflowActivity).toBe(true); + expect(result.get("owner/repo")!.hasPRActivity).toBe(true); }); it("does case-insensitive repo matching: Owner/Repo vs owner/repo", () => {