Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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. |
Expand Down
38 changes: 24 additions & 14 deletions mcp/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface CachedConfig {
trackedUsers: TrackedUser[];
upstreamRepos: RepoRef[];
monitoredRepos: RepoRef[];
enableActions: boolean;
}

let _cachedConfig: CachedConfig | null = null;
Expand Down Expand Up @@ -341,6 +342,7 @@ export class OctokitDataSource implements DataSource {
}

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

const pairs = repos.flatMap((r) =>
Expand Down Expand Up @@ -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 };
}

const repoFilter = repos.map((r) => `repo:${r.owner}/${r.name}`).join("+");
Expand All @@ -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 }),
Expand All @@ -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 };
}

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

async getFailingActions(repo?: string): Promise<WorkflowRun[]> {
return sendRelayRequest(METHODS.GET_FAILING_ACTIONS, { repo }) as Promise<WorkflowRun[]>;
const result = await sendRelayRequest(METHODS.GET_FAILING_ACTIONS, { repo });
if (result !== null && typeof result === "object" && !Array.isArray(result) && "disabled" in (result as Record<string, unknown>)) {
return [];
}
return result as WorkflowRun[];
}

async getPRDetails(repo: string, number: number): Promise<PullRequest | null> {
Expand Down
1 change: 1 addition & 0 deletions mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down
12 changes: 10 additions & 2 deletions mcp/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`,
];
Expand Down Expand Up @@ -186,6 +189,11 @@ export function registerTools(server: McpServer, dataSource: DataSource): void {
async (args) => {
const { repo } = args as { repo?: string };
try {
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();
Expand Down
71 changes: 66 additions & 5 deletions mcp/tests/data-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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()],
Expand All @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand All @@ -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: {} };
Expand Down Expand Up @@ -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", () => {
Expand All @@ -565,6 +615,7 @@ describe("OctokitDataSource", () => {
trackedUsers: [],
upstreamRepos: [],
monitoredRepos: [],
enableActions: true,
};
setCachedConfig(config);
const ds = new OctokitDataSource({ request: vi.fn() });
Expand Down Expand Up @@ -641,6 +692,7 @@ describe("CompositeDataSource", () => {
trackedUsers: [],
upstreamRepos: [],
monitoredRepos: [],
enableActions: true,
});
});

Expand Down Expand Up @@ -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([]);
});
});
3 changes: 2 additions & 1 deletion mcp/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ describe("Integration: Edge cases (with server)", () => {
trackedUsers: [],
upstreamRepos: [],
monitoredRepos: [],
enableActions: true,
});

if (!wss) throw new Error("Server not started");
Expand All @@ -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" }]),
};
Expand Down
1 change: 1 addition & 0 deletions mcp/tests/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function makeConfig(overrides: Partial<CachedConfig> = {}): CachedConfig {
trackedUsers: [],
upstreamRepos: [],
monitoredRepos: [],
enableActions: true,
...overrides,
};
}
Expand Down
51 changes: 51 additions & 0 deletions mcp/tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading