Skip to content

Commit 78a546f

Browse files
committed
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
1 parent 7fdc8a8 commit 78a546f

18 files changed

Lines changed: 816 additions & 64 deletions

File tree

mcp/src/data-source.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface CachedConfig {
2626
trackedUsers: TrackedUser[];
2727
upstreamRepos: RepoRef[];
2828
monitoredRepos: RepoRef[];
29+
enableActions: boolean;
2930
}
3031

3132
let _cachedConfig: CachedConfig | null = null;
@@ -341,6 +342,7 @@ export class OctokitDataSource implements DataSource {
341342
}
342343

343344
async getFailingActions(repo?: string): Promise<WorkflowRun[]> {
345+
if (_cachedConfig?.enableActions === false) return [];
344346
const repos = resolveRepos(repo);
345347

346348
const pairs = repos.flatMap((r) =>
@@ -451,8 +453,9 @@ export class OctokitDataSource implements DataSource {
451453
const login = await this.getLogin();
452454
const repos = _cachedConfig?.selectedRepos ?? [];
453455

456+
const actionsEnabled = _cachedConfig?.enableActions !== false;
454457
if (repos.length === 0) {
455-
return { openPRCount: 0, openIssueCount: 0, failingRunCount: 0, needsReviewCount: 0, approvedUnmergedCount: 0 };
458+
return { openPRCount: 0, openIssueCount: 0, failingRunCount: actionsEnabled ? 0 : null, needsReviewCount: 0, approvedUnmergedCount: 0, actionsMonitoringDisabled: !actionsEnabled };
456459
}
457460

458461
const repoFilter = repos.map((r) => `repo:${r.owner}/${r.name}`).join("+");
@@ -463,7 +466,6 @@ export class OctokitDataSource implements DataSource {
463466
let needsReviewCount = 0;
464467
// REST search lacks reviewDecision data — approved count requires GraphQL (relay path only)
465468
const approvedUnmergedCount = 0;
466-
let failingRunCount = 0;
467469

468470
const [prResult, issueResult, reviewResult] = await Promise.allSettled([
469471
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 {
487489
console.error("[mcp] getDashboardSummary review count error:", reviewResult.reason instanceof Error ? reviewResult.reason.message : String(reviewResult.reason));
488490
}
489491

490-
const failingRunResults = await Promise.allSettled(
491-
repos.map((r) =>
492-
this.octokit.request(
493-
"GET /repos/{owner}/{repo}/actions/runs",
494-
{ owner: r.owner, repo: r.name, status: "failure", per_page: 5 }
492+
let finalFailingRunCount: number | null = null;
493+
if (actionsEnabled) {
494+
const failingRunResults = await Promise.allSettled(
495+
repos.map((r) =>
496+
this.octokit.request(
497+
"GET /repos/{owner}/{repo}/actions/runs",
498+
{ owner: r.owner, repo: r.name, status: "failure", per_page: 5 }
499+
)
495500
)
496-
)
497-
);
498-
for (const settled of failingRunResults) {
499-
if (settled.status === "fulfilled") {
500-
failingRunCount += (settled.value.data as { total_count: number }).total_count;
501+
);
502+
finalFailingRunCount = 0;
503+
for (const settled of failingRunResults) {
504+
if (settled.status === "fulfilled") {
505+
finalFailingRunCount += (settled.value.data as { total_count: number }).total_count;
506+
}
501507
}
502508
}
503509

504-
return { openPRCount, openIssueCount, failingRunCount, needsReviewCount, approvedUnmergedCount };
510+
return { openPRCount, openIssueCount, failingRunCount: finalFailingRunCount, needsReviewCount, approvedUnmergedCount, actionsMonitoringDisabled: !actionsEnabled };
505511
}
506512

507513
async getConfig(): Promise<CachedConfig | null> {

mcp/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const ConfigUpdatePayloadSchema = z.object({
3434
trackedUsers: TrackedUserSchema.array().max(MAX_TRACKED_USERS).default([]),
3535
upstreamRepos: RepoRefSchema.array().max(MAX_REPOS).default([]),
3636
monitoredRepos: RepoRefSchema.array().max(MAX_MONITORED_REPOS).default([]),
37+
enableActions: z.boolean().default(true),
3738
});
3839

3940
// ── Main entry point ──────────────────────────────────────────────────────────

mcp/src/tools.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55
import { z } from "zod";
66
import { METHODS } from "../../src/shared/protocol.js";
7-
import type { DataSource } from "./data-source.js";
7+
import type { DataSource, CachedConfig } from "./data-source.js";
88
import type {
99
Issue,
1010
PullRequest,
@@ -68,12 +68,15 @@ function formatRun(run: WorkflowRun, index: number): string {
6868
}
6969

7070
function formatSummary(summary: DashboardSummary, scope: string): string {
71+
const failingLine = summary.failingRunCount === null
72+
? "Failing CI Runs: — (Actions monitoring disabled)"
73+
: `Failing CI Runs: ${summary.failingRunCount}`;
7174
const lines: string[] = [
7275
`GitHub Tracker Dashboard Summary (scope: ${scope})`,
7376
"─".repeat(50),
7477
`Open PRs: ${summary.openPRCount}`,
7578
`Open Issues: ${summary.openIssueCount}`,
76-
`Failing CI Runs: ${summary.failingRunCount}`,
79+
failingLine,
7780
`Needs Review: ${summary.needsReviewCount}`,
7881
`Approved/Unmerged: ${summary.approvedUnmergedCount}`,
7982
];
@@ -186,6 +189,12 @@ export function registerTools(server: McpServer, dataSource: DataSource): void {
186189
async (args) => {
187190
const { repo } = args as { repo?: string };
188191
try {
192+
// SEC-010: check config before calling getFailingActions to surface disabled state
193+
const cachedConfig: CachedConfig | null = await dataSource.getConfig();
194+
if (cachedConfig?.enableActions === false) {
195+
const text = "GitHub Actions monitoring is disabled in the dashboard. Enable it in Settings to track workflow runs." + stalenessLine();
196+
return { content: [{ type: "text" as const, text }] };
197+
}
189198
const runs = await dataSource.getFailingActions(repo);
190199
if (runs.length === 0) {
191200
const text = `No failing or in-progress workflow runs found${repo ? ` in ${repo}` : ""}.` + stalenessLine();

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,8 @@ export default function DashboardPage() {
532532
const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab;
533533
if (tab === "tracked" && !config.enableTracking) return "issues";
534534
if (tab === "jiraAssigned" && !config.jira?.enabled) return "issues";
535+
if (tab === "actions" && !config.enableActions) return "issues";
536+
if (!config.enableActions && !isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions") return "issues";
535537
// Validate custom tab still exists; fall back to "issues" if stale
536538
if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return "issues";
537539
return tab;
@@ -542,6 +544,10 @@ export default function DashboardPage() {
542544
function handleTabChange(tab: TabId) {
543545
// Reject invalid tab IDs to prevent persisting stale state
544546
if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return;
547+
if (!config.enableActions) {
548+
if (tab === "actions") return;
549+
if (!isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions") return;
550+
}
545551
setActiveTab(tab);
546552
updateViewState({ lastActiveTab: tab });
547553
}
@@ -569,6 +575,15 @@ export default function DashboardPage() {
569575
}
570576
});
571577

578+
// Redirect away from Actions tab (or actions-based custom tab) when Actions is disabled
579+
createEffect(() => {
580+
if (!config.enableActions) {
581+
const tab = activeTab();
582+
const isActionsTab = tab === "actions" || (!isBuiltinTab(tab) && config.customTabs.find((t) => t.id === tab)?.baseType === "actions");
583+
if (isActionsTab) handleTabChange("issues");
584+
}
585+
});
586+
572587
// Clear stale Jira data when auth is cleared (e.g., 401 during token refresh)
573588
createEffect(() => {
574589
if (!isJiraAuthenticated()) {
@@ -792,6 +807,7 @@ export default function DashboardPage() {
792807
const currentTabId = activeTab();
793808
const result: Record<string, { issues: typeof dashboardData.issues; pullRequests: typeof dashboardData.pullRequests; workflowRuns: typeof dashboardData.workflowRuns }> = {};
794809
for (const tab of config.customTabs) {
810+
if (!config.enableActions && tab.baseType === "actions") continue;
795811
if (!tab.exclusive && tab.id !== currentTabId) continue;
796812
const matchesScope = buildTabScopeMatcher(tab);
797813
result[tab.id] = {
@@ -866,6 +882,7 @@ export default function DashboardPage() {
866882
const users = allUsers();
867883
const customCounts: Record<string, number> = {};
868884
for (const tab of config.customTabs) {
885+
if (!config.enableActions && tab.baseType === "actions") continue;
869886
// customTabData skips non-exclusive inactive tabs (perf optimization),
870887
// so compute scope on demand for tabs absent from the memo.
871888
let data = customTabData()[tab.id];
@@ -968,9 +985,9 @@ export default function DashboardPage() {
968985
pullRequests: visiblePullRequests().filter((p) =>
969986
isPrVisible(p, { ignoredIds: ignoredPRs, globalFilter: builtinFilter })
970987
).length,
971-
actions: visibleWorkflowRuns().filter((w) =>
988+
...(config.enableActions ? { actions: visibleWorkflowRuns().filter((w) =>
972989
isRunVisible(w, { ignoredIds: ignoredRuns, showPrRuns: viewState.showPrRuns, globalFilter: builtinFilter })
973-
).length,
990+
).length } : {}),
974991
...(config.enableTracking ? { tracked: viewState.trackedItems.length } : {}),
975992
...(config.jira?.enabled ? (() => {
976993
const f = viewState.tabFilters.jiraAssigned;
@@ -1053,6 +1070,14 @@ export default function DashboardPage() {
10531070
{ defer: true }
10541071
));
10551072

1073+
// When Actions is disabled, clear stale workflowRuns from the store so memos
1074+
// computing against empty workflowRuns don't process cached data (SEC-004).
1075+
createEffect(() => {
1076+
if (!config.enableActions) {
1077+
setDashboardData(produce((d) => { d.workflowRuns = []; }));
1078+
}
1079+
});
1080+
10561081
// Push dashboard data into the MCP relay snapshot on each full refresh.
10571082
// Tracks lastRefreshedAt (always updated alongside data arrays in pollFetch).
10581083
// Hot poll updates are intentionally excluded — relay reflects full-refresh data only.
@@ -1064,6 +1089,7 @@ export default function DashboardPage() {
10641089
issues: d.issues,
10651090
pullRequests: d.pullRequests,
10661091
workflowRuns: d.workflowRuns,
1092+
enableActions: config.enableActions,
10671093
lastUpdatedAt: Date.now(),
10681094
});
10691095
});
@@ -1092,8 +1118,9 @@ export default function DashboardPage() {
10921118
onTabChange={handleTabChange}
10931119
counts={tabCounts()}
10941120
enableTracking={config.enableTracking}
1121+
enableActions={config.enableActions}
10951122
enableJira={!!config.jira?.enabled}
1096-
customTabs={config.customTabs.map((t) => ({ id: t.id, name: t.name }))}
1123+
customTabs={config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ id: t.id, name: t.name }))}
10971124
onAddTab={() => setShowCustomTabModal(true)}
10981125
onEditTab={(id) => { setEditingTabId(id); setShowCustomTabModal(true); }}
10991126
/>
@@ -1156,7 +1183,7 @@ export default function DashboardPage() {
11561183
siteUrl={config.jira?.siteUrl ?? ""}
11571184
/>
11581185
</Match>
1159-
<Match when={activeTab() === "actions"}>
1186+
<Match when={activeTab() === "actions" && config.enableActions}>
11601187
<ActionsTab
11611188
workflowRuns={visibleWorkflowRuns()}
11621189
loading={dashboardData.loading}
@@ -1237,6 +1264,7 @@ export default function DashboardPage() {
12371264
editingTab={editingTab()}
12381265
availableOrgs={[...new Set(config.selectedRepos.map((r) => r.owner))]}
12391266
availableRepos={config.selectedRepos}
1267+
enableActions={config.enableActions}
12401268
/>
12411269
</div>
12421270

src/app/components/layout/TabBar.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface TabBarProps {
1111
onTabChange: (tab: TabId) => void;
1212
counts?: TabCounts;
1313
enableTracking?: boolean;
14+
enableActions?: boolean;
1415
enableJira?: boolean;
1516
customTabs?: Array<{ id: string; name: string }>;
1617
onAddTab?: () => void;
@@ -36,12 +37,14 @@ export default function TabBar(props: TabBarProps) {
3637
<span class="badge badge-sm badge-neutral ml-1">{props.counts?.pullRequests}</span>
3738
</Show>
3839
</Tabs.Trigger>
39-
<Tabs.Trigger value="actions" class="tab compact:tab-sm data-[selected]:tab-active">
40-
Actions
41-
<Show when={props.counts?.actions !== undefined}>
42-
<span class="badge badge-sm badge-neutral ml-1">{props.counts?.actions}</span>
43-
</Show>
44-
</Tabs.Trigger>
40+
<Show when={props.enableActions !== false}>
41+
<Tabs.Trigger value="actions" class="tab compact:tab-sm data-[selected]:tab-active">
42+
Actions
43+
<Show when={props.counts?.actions !== undefined}>
44+
<span class="badge badge-sm badge-neutral ml-1">{props.counts?.actions}</span>
45+
</Show>
46+
</Tabs.Trigger>
47+
</Show>
4548
<Show when={props.enableTracking}>
4649
<Tabs.Trigger value="tracked" class="tab compact:tab-sm data-[selected]:tab-active">
4750
Tracked

src/app/components/settings/SettingsPage.tsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export default function SettingsPage() {
177177
defaultTab: config.defaultTab,
178178
rememberLastTab: config.rememberLastTab,
179179
enableTracking: config.enableTracking,
180+
enableActions: config.enableActions,
180181
customTabs: config.customTabs,
181182
// Non-secret jira config fields only — no tokens, sealed blobs, or email
182183
jira: {
@@ -345,10 +346,10 @@ export default function SettingsPage() {
345346
const tabOptions = createMemo(() => [
346347
{ value: "issues", label: "Issues" },
347348
{ value: "pullRequests", label: "Pull Requests" },
348-
{ value: "actions", label: "GitHub Actions" },
349+
...(config.enableActions ? [{ value: "actions", label: "GitHub Actions" }] : []),
349350
...(config.enableTracking ? [{ value: "tracked", label: "Tracked Items" }] : []),
350351
...(config.jira?.enabled ? [{ value: "jiraAssigned", label: "Jira" }] : []),
351-
...config.customTabs.map((t) => ({ value: t.id, label: t.name })),
352+
...config.customTabs.filter((t) => config.enableActions || t.baseType !== "actions").map((t) => ({ value: t.id, label: t.name })),
352353
]);
353354

354355

@@ -607,6 +608,34 @@ export default function SettingsPage() {
607608

608609
{/* Section 5: GitHub Actions */}
609610
<Section title="GitHub Actions">
611+
<SettingRow
612+
label="Enable GitHub Actions"
613+
description="Track workflow runs and show the Actions tab. Disable to save API calls."
614+
>
615+
<input
616+
type="checkbox"
617+
role="switch"
618+
aria-checked={config.enableActions}
619+
aria-label="Enable GitHub Actions"
620+
checked={config.enableActions}
621+
onChange={(e) => {
622+
const val = e.currentTarget.checked;
623+
const isActionsCustomTab = (id: string) =>
624+
config.customTabs.some((t) => t.id === id && t.baseType === "actions");
625+
const needsDefaultReset = !val && (config.defaultTab === "actions" || isActionsCustomTab(config.defaultTab));
626+
const needsLastTabReset = !val && (viewState.lastActiveTab === "actions" || isActionsCustomTab(viewState.lastActiveTab));
627+
saveWithFeedback({
628+
enableActions: val,
629+
...(needsDefaultReset ? { defaultTab: "issues" as const } : {}),
630+
...(!val ? { notifications: { ...config.notifications, workflowRuns: false } } : {}),
631+
});
632+
if (needsLastTabReset) {
633+
updateViewState({ lastActiveTab: "issues" });
634+
}
635+
}}
636+
class="toggle toggle-primary"
637+
/>
638+
</SettingRow>
610639
<SettingRow
611640
label="Max workflows per repo"
612641
description="Number of active workflows to track per repository (1–20)"
@@ -616,13 +645,14 @@ export default function SettingsPage() {
616645
min={1}
617646
max={20}
618647
value={config.maxWorkflowsPerRepo}
648+
disabled={!config.enableActions}
619649
onInput={(e) => {
620650
const val = parseInt(e.currentTarget.value, 10);
621651
if (!isNaN(val) && val >= 1 && val <= 20) {
622652
saveWithFeedback({ maxWorkflowsPerRepo: val });
623653
}
624654
}}
625-
class="input input-sm w-20"
655+
class={`input input-sm w-20${!config.enableActions ? " opacity-50" : ""}`}
626656
/>
627657
</SettingRow>
628658
<SettingRow
@@ -634,13 +664,14 @@ export default function SettingsPage() {
634664
min={1}
635665
max={10}
636666
value={config.maxRunsPerWorkflow}
667+
disabled={!config.enableActions}
637668
onInput={(e) => {
638669
const val = parseInt(e.currentTarget.value, 10);
639670
if (!isNaN(val) && val >= 1 && val <= 10) {
640671
saveWithFeedback({ maxRunsPerWorkflow: val });
641672
}
642673
}}
643-
class="input input-sm w-20"
674+
class={`input input-sm w-20${!config.enableActions ? " opacity-50" : ""}`}
644675
/>
645676
</SettingRow>
646677
</Section>
@@ -719,14 +750,17 @@ export default function SettingsPage() {
719750
class="toggle toggle-primary"
720751
/>
721752
</SettingRow>
722-
<SettingRow label="Workflow Runs" description="Notify when workflow runs complete">
753+
<SettingRow
754+
label="Workflow Runs"
755+
description={!config.enableActions ? "Disabled — GitHub Actions is off" : "Notify when workflow runs complete"}
756+
>
723757
<input
724758
type="checkbox"
725759
role="switch"
726760
aria-checked={config.notifications.workflowRuns}
727761
aria-label="Workflow runs notifications"
728762
checked={config.notifications.workflowRuns}
729-
disabled={!config.notifications.enabled}
763+
disabled={!config.notifications.enabled || !config.enableActions}
730764
onChange={(e) =>
731765
saveWithFeedback({
732766
notifications: { ...config.notifications, workflowRuns: e.currentTarget.checked },

src/app/components/shared/CustomTabModal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface CustomTabModalProps {
1919
editingTab?: CustomTab;
2020
availableOrgs: string[];
2121
availableRepos: RepoRef[];
22+
enableActions?: boolean;
2223
}
2324

2425
// 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) {
241242
>
242243
<option value="issues">Issues</option>
243244
<option value="pullRequests">Pull Requests</option>
244-
<option value="actions">Actions</option>
245+
<Show when={props.enableActions !== false}>
246+
<option value="actions">Actions</option>
247+
</Show>
245248
</select>
246249
</div>
247250

0 commit comments

Comments
 (0)