Skip to content

Commit e8575d6

Browse files
authored
feat(actions): adds optional Actions tab toggle (#102)
* 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 * 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. * 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. * 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. * 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. * chore: removes internal review IDs from comments SEC-004/SEC-010 references are swarm audit trail identifiers that have no meaning in production code. * 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
1 parent d55a4db commit e8575d6

28 files changed

Lines changed: 1015 additions & 88 deletions

docs/USER_GUIDE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ Sort by: Repo, Title, Author, Checks, Review, Size, Created, Updated (default: U
240240

241241
## Actions Tab
242242

243+
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.
244+
243245
### Workflow Grouping
244246

245247
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.
575577
|---------|---------|-------------|
576578
| Refresh interval | 5 minutes | How often to poll GitHub for new data. Options: 1, 2, 5, 10, 15, 30 minutes, or Off. |
577579
| CI status refresh (hot poll interval) | 30 seconds | How often to re-check in-flight CI checks and workflow runs. Range: 10–120 seconds. |
578-
| Max workflows per repo | 5 | Number of active workflows to track per repository. Range: 1–20. |
579-
| Max runs per workflow | 3 | Number of recent runs to show per workflow. Range: 1–10. |
580+
| Show Actions tab | On | Show the Actions tab and track workflow runs. Disable to skip all workflow run API calls and simplify the dashboard. |
581+
| Max workflows per repo | 5 | Number of active workflows to track per repository. Range: 1–20. Disabled when Actions is off. |
582+
| Max runs per workflow | 3 | Number of recent runs to show per workflow. Range: 1–10. Disabled when Actions is off. |
580583
| Notifications enabled | Off | Master toggle for browser push notifications. |
581584
| Notify: Issues | On | Notify when new issues open (requires notifications enabled). |
582585
| Notify: Pull Requests | On | Notify when PRs are opened or updated (requires notifications enabled). |
583-
| Notify: Workflow Runs | On | Notify when workflow runs complete (requires notifications enabled). |
586+
| Notify: Workflow Runs | On | Notify when workflow runs complete (requires notifications enabled). Disabled when Actions is off. |
584587
| Theme | Auto | UI color theme. Auto follows system dark/light preference (Corporate for light, Dim for dark). |
585588
| View density | Comfortable | Spacing between list items. Options: Comfortable, Compact. |
586589
| Items per page | 25 | Number of items per page in each tab. Options: 10, 25, 50, 100. |

mcp/src/data-source.ts

Lines changed: 24 additions & 14 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 };
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 };
505511
}
506512

507513
async getConfig(): Promise<CachedConfig | null> {
@@ -530,7 +536,11 @@ export class WebSocketDataSource implements DataSource {
530536
}
531537

532538
async getFailingActions(repo?: string): Promise<WorkflowRun[]> {
533-
return sendRelayRequest(METHODS.GET_FAILING_ACTIONS, { repo }) as Promise<WorkflowRun[]>;
539+
const result = await sendRelayRequest(METHODS.GET_FAILING_ACTIONS, { repo });
540+
if (result !== null && typeof result === "object" && !Array.isArray(result) && "disabled" in (result as Record<string, unknown>)) {
541+
return [];
542+
}
543+
return result as WorkflowRun[];
534544
}
535545

536546
async getPRDetails(repo: string, number: number): Promise<PullRequest | 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: 10 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,11 @@ export function registerTools(server: McpServer, dataSource: DataSource): void {
186189
async (args) => {
187190
const { repo } = args as { repo?: string };
188191
try {
192+
const cachedConfig: CachedConfig | null = await dataSource.getConfig();
193+
if (cachedConfig?.enableActions === false) {
194+
const text = "GitHub Actions monitoring is disabled in the dashboard. Enable it in Settings to track workflow runs." + stalenessLine();
195+
return { content: [{ type: "text" as const, text }] };
196+
}
189197
const runs = await dataSource.getFailingActions(repo);
190198
if (runs.length === 0) {
191199
const text = `No failing or in-progress workflow runs found${repo ? ` in ${repo}` : ""}.` + stalenessLine();

mcp/tests/data-source.test.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,14 @@ describe("OctokitDataSource", () => {
117117
trackedUsers: [],
118118
upstreamRepos: [],
119119
monitoredRepos: [],
120+
enableActions: true,
120121
});
121122
});
122123

123124
afterEach(() => {
124125
vi.restoreAllMocks();
125126
// Clear cached config
126-
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] });
127+
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true });
127128
});
128129

129130
describe("getOpenPRs", () => {
@@ -189,7 +190,7 @@ describe("OctokitDataSource", () => {
189190

190191
it("accepts explicit repo parameter and skips cached config", async () => {
191192
// Clear cached config to verify explicit param works without it
192-
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] });
193+
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true });
193194

194195
const responses = new Map([
195196
["GET /user", makeUserResponse()],
@@ -208,7 +209,7 @@ describe("OctokitDataSource", () => {
208209

209210
it("returns empty array when config has no repos and no explicit repo", async () => {
210211
// setCachedConfig with empty selectedRepos → resolveRepos returns []
211-
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] });
212+
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true });
212213
const responses = new Map([["GET /user", makeUserResponse()]]);
213214
const octokit = makeMockOctokit(responses);
214215
const ds = new OctokitDataSource(octokit);
@@ -433,11 +434,26 @@ describe("OctokitDataSource", () => {
433434
});
434435

435436
it("returns empty array when config has no repos and no explicit repo", async () => {
436-
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] });
437+
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true });
437438
const ds = new OctokitDataSource({ request: vi.fn() });
438439
const runs = await ds.getFailingActions();
439440
expect(runs).toEqual([]);
440441
});
442+
443+
it("returns empty array when enableActions is false", async () => {
444+
setCachedConfig({
445+
selectedRepos: [{ owner: "owner", name: "repo", fullName: "owner/repo" }],
446+
trackedUsers: [],
447+
upstreamRepos: [],
448+
monitoredRepos: [],
449+
enableActions: false,
450+
});
451+
const requestMock = vi.fn();
452+
const ds = new OctokitDataSource({ request: requestMock });
453+
const runs = await ds.getFailingActions();
454+
expect(runs).toEqual([]);
455+
expect(requestMock).not.toHaveBeenCalled();
456+
});
441457
});
442458

443459
describe("getPRDetails", () => {
@@ -502,7 +518,7 @@ describe("OctokitDataSource", () => {
502518

503519
describe("getDashboardSummary", () => {
504520
it("returns zero counts when no repos are configured", async () => {
505-
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [] });
521+
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true });
506522
const octokit = makeMockOctokit(new Map([["GET /user", makeUserResponse()]]));
507523
const ds = new OctokitDataSource(octokit);
508524
const summary = await ds.getDashboardSummary("involves_me");
@@ -514,6 +530,16 @@ describe("OctokitDataSource", () => {
514530
expect(summary.approvedUnmergedCount).toBe(0);
515531
});
516532

533+
it("returns failingRunCount=null in early return when no repos and enableActions is false", async () => {
534+
setCachedConfig({ selectedRepos: [], trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: false });
535+
const octokit = makeMockOctokit(new Map([["GET /user", makeUserResponse()]]));
536+
const ds = new OctokitDataSource(octokit);
537+
const summary = await ds.getDashboardSummary("involves_me");
538+
539+
expect(summary.failingRunCount).toBeNull();
540+
expect(summary.openPRCount).toBe(0);
541+
});
542+
517543
it("constructs involves_me query with user login", async () => {
518544
const requestMock = vi.fn().mockImplementation(async (route: string) => {
519545
if (route === "GET /user") return { data: { login: "testuser" }, headers: {} };
@@ -556,6 +582,30 @@ describe("OctokitDataSource", () => {
556582
expect(prCall).toBeDefined();
557583
expect(prCall![1].q).not.toContain("involves:");
558584
});
585+
586+
it("returns failingRunCount=null and skips actions API when enableActions is false", async () => {
587+
setCachedConfig({
588+
selectedRepos: [{ owner: "owner", name: "repo", fullName: "owner/repo" }],
589+
trackedUsers: [],
590+
upstreamRepos: [],
591+
monitoredRepos: [],
592+
enableActions: false,
593+
});
594+
const requestMock = vi.fn().mockImplementation(async (route: string) => {
595+
if (route === "GET /user") return { data: { login: "testuser" }, headers: {} };
596+
if (route === "GET /search/issues") return { data: { items: [], total_count: 0 }, headers: {} };
597+
throw new Error(`Unexpected: ${route}`);
598+
});
599+
600+
const ds = new OctokitDataSource({ request: requestMock });
601+
const summary = await ds.getDashboardSummary("involves_me");
602+
603+
expect(summary.failingRunCount).toBeNull();
604+
const actionsCalls = requestMock.mock.calls.filter(
605+
([route]: [string]) => route === "GET /repos/{owner}/{repo}/actions/runs"
606+
);
607+
expect(actionsCalls).toHaveLength(0);
608+
});
559609
});
560610

561611
describe("getConfig", () => {
@@ -565,6 +615,7 @@ describe("OctokitDataSource", () => {
565615
trackedUsers: [],
566616
upstreamRepos: [],
567617
monitoredRepos: [],
618+
enableActions: true,
568619
};
569620
setCachedConfig(config);
570621
const ds = new OctokitDataSource({ request: vi.fn() });
@@ -641,6 +692,7 @@ describe("CompositeDataSource", () => {
641692
trackedUsers: [],
642693
upstreamRepos: [],
643694
monitoredRepos: [],
695+
enableActions: true,
644696
});
645697
});
646698

@@ -755,4 +807,13 @@ describe("CompositeDataSource", () => {
755807
expect(octokitDs.getRateLimit).toHaveBeenCalled();
756808
expect(_mockSendRequest).not.toHaveBeenCalled();
757809
});
810+
811+
it("WebSocketDataSource.getFailingActions returns empty array for disabled sentinel", async () => {
812+
_mockIsConnected = true;
813+
_mockSendRequest = vi.fn().mockResolvedValue({ disabled: true, message: "Actions monitoring is disabled" });
814+
815+
const wsDs = new WebSocketDataSource();
816+
const result = await wsDs.getFailingActions();
817+
expect(result).toEqual([]);
818+
});
758819
});

mcp/tests/integration.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ describe("Integration: Edge cases (with server)", () => {
439439
trackedUsers: [],
440440
upstreamRepos: [],
441441
monitoredRepos: [],
442+
enableActions: true,
442443
});
443444

444445
if (!wss) throw new Error("Server not started");
@@ -461,7 +462,7 @@ describe("Integration: Edge cases (with server)", () => {
461462
getRateLimit: vi.fn(),
462463
getConfig: vi.fn().mockResolvedValue({
463464
selectedRepos: [{ owner: "acme", name: "app", fullName: "acme/app" }],
464-
trackedUsers: [], upstreamRepos: [], monitoredRepos: [],
465+
trackedUsers: [], upstreamRepos: [], monitoredRepos: [], enableActions: true,
465466
}),
466467
getRepos: vi.fn().mockResolvedValue([{ owner: "acme", name: "app", fullName: "acme/app" }]),
467468
};

mcp/tests/resources.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function makeConfig(overrides: Partial<CachedConfig> = {}): CachedConfig {
5858
trackedUsers: [],
5959
upstreamRepos: [],
6060
monitoredRepos: [],
61+
enableActions: true,
6162
...overrides,
6263
};
6364
}

mcp/tests/tools.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ describe("get_dashboard_summary", () => {
125125
// isRelayConnected is mocked to return false, so staleness note should be present
126126
expect(result.content[0].text).toContain("data via GitHub API");
127127
});
128+
129+
it("shows actions disabled indicator when failingRunCount is null", async () => {
130+
const disabledSummary: DashboardSummary = {
131+
openPRCount: 2,
132+
openIssueCount: 1,
133+
failingRunCount: null,
134+
needsReviewCount: 0,
135+
approvedUnmergedCount: 0,
136+
};
137+
vi.mocked(ds.getDashboardSummary).mockResolvedValueOnce(disabledSummary);
138+
const result = await callTool(server, "get_dashboard_summary");
139+
expect(result.isError).toBeFalsy();
140+
const text = result.content[0].text;
141+
expect(text).toContain("Actions monitoring disabled");
142+
expect(text).not.toMatch(/Failing CI Runs:\s+\d/);
143+
});
128144
});
129145

130146
describe("get_open_prs", () => {
@@ -315,6 +331,41 @@ describe("get_failing_actions", () => {
315331
expect(result.isError).toBe(true);
316332
expect(result.content[0].text).toContain("Error fetching workflow runs");
317333
});
334+
335+
it("returns disabled message when enableActions is false", async () => {
336+
vi.mocked(ds.getConfig).mockResolvedValueOnce({
337+
selectedRepos: [{ owner: "owner", name: "repo", fullName: "owner/repo" }],
338+
trackedUsers: [],
339+
upstreamRepos: [],
340+
monitoredRepos: [],
341+
enableActions: false,
342+
});
343+
const result = await callTool(server, "get_failing_actions");
344+
expect(result.isError).toBeFalsy();
345+
expect(result.content[0].text).toContain("Actions monitoring is disabled");
346+
expect(ds.getFailingActions).not.toHaveBeenCalled();
347+
});
348+
349+
it("proceeds normally when enableActions is true", async () => {
350+
vi.mocked(ds.getConfig).mockResolvedValueOnce({
351+
selectedRepos: [{ owner: "owner", name: "repo", fullName: "owner/repo" }],
352+
trackedUsers: [],
353+
upstreamRepos: [],
354+
monitoredRepos: [],
355+
enableActions: true,
356+
});
357+
const result = await callTool(server, "get_failing_actions");
358+
expect(result.isError).toBeFalsy();
359+
expect(result.content[0].text).toContain("No failing or in-progress workflow runs found");
360+
expect(ds.getFailingActions).toHaveBeenCalled();
361+
});
362+
363+
it("proceeds normally when getConfig returns null", async () => {
364+
vi.mocked(ds.getConfig).mockResolvedValueOnce(null);
365+
const result = await callTool(server, "get_failing_actions");
366+
expect(result.isError).toBeFalsy();
367+
expect(ds.getFailingActions).toHaveBeenCalled();
368+
});
318369
});
319370

320371
describe("get_pr_details", () => {

0 commit comments

Comments
 (0)