Skip to content

Commit 709d3e8

Browse files
committed
feat: add gh_auth_status, actions_runs_filter, labels_sync, check_run_create tools
1 parent 9855bc3 commit 709d3e8

9 files changed

Lines changed: 785 additions & 0 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { registerActionsRunsFilterTool } from "./actions-runs-filter-tool.js";
4+
import { captureTool } from "./test-harness.js";
5+
6+
describe("actions_runs_filter tool", () => {
7+
const run = captureTool(registerActionsRunsFilterTool);
8+
9+
test("returns error for missing authentication", async () => {
10+
const text = await run({
11+
owner: "nonexistent",
12+
repo: "nonexistent",
13+
});
14+
const parsed = JSON.parse(text) as { error?: { code: string }; runs?: unknown[] };
15+
16+
// May return AUTH_MISSING or runs list depending on environment
17+
if (parsed.error) {
18+
expect(parsed.error.code).toBeDefined();
19+
}
20+
});
21+
22+
test("returns runs list structure when successful", async () => {
23+
const text = await run({
24+
owner: "Rethunk-AI",
25+
repo: "rethunk-github-mcp",
26+
limit: 5,
27+
});
28+
const parsed = JSON.parse(text) as {
29+
error?: { code: string };
30+
runs?: Array<{
31+
id: number;
32+
name: string;
33+
status: string;
34+
conclusion: string | null;
35+
branch: string;
36+
createdAt: string;
37+
url: string;
38+
}>;
39+
};
40+
41+
// If no auth error
42+
if (!parsed.error || parsed.error.code !== "AUTH_MISSING") {
43+
if (parsed.runs !== undefined) {
44+
expect(Array.isArray(parsed.runs)).toBe(true);
45+
if (parsed.runs.length > 0) {
46+
const run = parsed.runs[0];
47+
expect(typeof run.id).toBe("number");
48+
expect(typeof run.name).toBe("string");
49+
expect(typeof run.status).toBe("string");
50+
expect(typeof run.createdAt).toBe("string");
51+
expect(typeof run.url).toBe("string");
52+
}
53+
}
54+
}
55+
});
56+
57+
test("respects limit parameter", async () => {
58+
const text = await run({
59+
owner: "Rethunk-AI",
60+
repo: "rethunk-github-mcp",
61+
limit: 3,
62+
});
63+
const parsed = JSON.parse(text) as { error?: { code: string }; runs?: unknown[] };
64+
65+
if (!parsed.error && parsed.runs) {
66+
expect(parsed.runs.length).toBeLessThanOrEqual(3);
67+
}
68+
});
69+
70+
test("accepts optional filters", async () => {
71+
const text = await run({
72+
owner: "Rethunk-AI",
73+
repo: "rethunk-github-mcp",
74+
status: "completed",
75+
limit: 10,
76+
});
77+
const parsed = JSON.parse(text) as { error?: { code: string }; runs?: unknown[] };
78+
79+
if (!parsed.error && parsed.runs) {
80+
// Should not throw
81+
expect(Array.isArray(parsed.runs)).toBe(true);
82+
}
83+
});
84+
85+
test("handles workflow filter", async () => {
86+
const text = await run({
87+
owner: "Rethunk-AI",
88+
repo: "rethunk-github-mcp",
89+
workflow: "CI",
90+
limit: 5,
91+
});
92+
const parsed = JSON.parse(text) as { error?: { code: string }; runs?: unknown[] };
93+
94+
if (!parsed.error && parsed.runs) {
95+
// Workflow filter applied
96+
expect(Array.isArray(parsed.runs)).toBe(true);
97+
}
98+
});
99+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
import { classifyError, getOctokit } from "./github-client.js";
4+
import { errorRespond, jsonRespond } from "./json.js";
5+
6+
export interface WorkflowRun {
7+
id: number;
8+
name: string;
9+
status: string;
10+
conclusion: string | null;
11+
branch: string;
12+
createdAt: string;
13+
url: string;
14+
}
15+
16+
export interface ActionsRunsFilterResult {
17+
runs: WorkflowRun[];
18+
}
19+
20+
export function registerActionsRunsFilterTool(server: FastMCP): void {
21+
server.addTool({
22+
name: "actions_runs_filter",
23+
description:
24+
"List and filter GitHub Actions workflow runs for a repository. Filter by workflow name, status, conclusion, and branch.",
25+
annotations: { readOnlyHint: true },
26+
parameters: z.object({
27+
owner: z.string().describe("GitHub owner or organization."),
28+
repo: z.string().describe("GitHub repository name."),
29+
workflow: z.string().optional().describe("Workflow name or ID to filter by."),
30+
status: z
31+
.enum(["queued", "in_progress", "completed"])
32+
.optional()
33+
.describe("Filter by status: queued, in_progress, or completed."),
34+
conclusion: z
35+
.enum(["success", "failure", "cancelled"])
36+
.optional()
37+
.describe("Filter by conclusion: success, failure, or cancelled."),
38+
branch: z.string().optional().describe("Filter by branch name."),
39+
limit: z
40+
.number()
41+
.int()
42+
.min(1)
43+
.max(100)
44+
.optional()
45+
.default(20)
46+
.describe("Maximum number of runs to return (default 20)."),
47+
}),
48+
execute: async (args) => {
49+
try {
50+
const octokit = getOctokit();
51+
const { owner, repo, workflow, status, conclusion, branch, limit } = args;
52+
53+
// Prepare API call parameters with proper types
54+
const apiParams: Parameters<typeof octokit.actions.listWorkflowRunsForRepo>[0] = {
55+
owner,
56+
repo,
57+
per_page: Math.min(limit, 100),
58+
};
59+
60+
// Add optional parameters with proper type casting
61+
if (status === "queued" || status === "in_progress" || status === "completed") {
62+
apiParams.status = status;
63+
}
64+
if (conclusion === "success" || conclusion === "failure" || conclusion === "cancelled") {
65+
apiParams.conclusion = conclusion;
66+
}
67+
if (branch) {
68+
apiParams.head_branch = branch;
69+
}
70+
71+
// List workflow runs
72+
const response = await octokit.actions.listWorkflowRunsForRepo(apiParams);
73+
74+
// Filter by workflow name if provided
75+
let runs = response.data.workflow_runs || [];
76+
if (workflow) {
77+
runs = runs.filter((run) =>
78+
(run.name ?? "").toLowerCase().includes(workflow.toLowerCase()),
79+
);
80+
}
81+
82+
// Map to output format and limit results
83+
const result: ActionsRunsFilterResult = {
84+
runs: runs.slice(0, limit).map((run) => ({
85+
id: run.id,
86+
name: run.name ?? "",
87+
status: run.status ?? "",
88+
conclusion: run.conclusion,
89+
branch: run.head_branch ?? "",
90+
createdAt: run.created_at ?? "",
91+
url: run.html_url ?? "",
92+
})),
93+
};
94+
95+
return jsonRespond(result);
96+
} catch (err) {
97+
console.error(
98+
`[actions_runs_filter] Failed to list workflow runs for ${args.owner}/${args.repo}:`,
99+
err instanceof Error ? err.message : String(err),
100+
);
101+
return errorRespond(classifyError(err));
102+
}
103+
},
104+
});
105+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { registerCheckRunCreateTool } from "./check-run-create-tool.js";
4+
import { captureTool } from "./test-harness.js";
5+
6+
describe("check_run_create tool", () => {
7+
const run = captureTool(registerCheckRunCreateTool);
8+
9+
test("requires conclusion when status is completed", async () => {
10+
const text = await run({
11+
owner: "test",
12+
repo: "test",
13+
name: "Test Check",
14+
headSha: "abc123",
15+
status: "completed",
16+
});
17+
const parsed = JSON.parse(text) as { error?: { code: string } };
18+
19+
// Should return validation error for missing conclusion
20+
if (!parsed.error || parsed.error.code !== "AUTH_MISSING") {
21+
if (parsed.error) {
22+
expect(parsed.error.code).toBe("VALIDATION");
23+
}
24+
}
25+
});
26+
27+
test("returns error for missing authentication", async () => {
28+
const text = await run({
29+
owner: "nonexistent",
30+
repo: "nonexistent",
31+
name: "Test Check",
32+
headSha: "abc123",
33+
status: "queued",
34+
});
35+
const parsed = JSON.parse(text) as { error?: { code: string } };
36+
37+
// May return AUTH_MISSING depending on environment
38+
if (parsed.error) {
39+
expect(parsed.error.code).toBeDefined();
40+
}
41+
});
42+
43+
test("returns check run structure when successful", async () => {
44+
const text = await run({
45+
owner: "Rethunk-AI",
46+
repo: "rethunk-github-mcp",
47+
name: "Test Check",
48+
headSha: "abc1234567890123456789012345678901234567",
49+
status: "queued",
50+
});
51+
const parsed = JSON.parse(text) as {
52+
error?: { code: string };
53+
id?: number;
54+
url?: string;
55+
};
56+
57+
if (!parsed.error || parsed.error.code !== "AUTH_MISSING") {
58+
if (!parsed.error && parsed.id !== undefined) {
59+
expect(typeof parsed.id).toBe("number");
60+
expect(typeof parsed.url).toBe("string");
61+
}
62+
}
63+
});
64+
65+
test("accepts optional title and summary", async () => {
66+
const text = await run({
67+
owner: "Rethunk-AI",
68+
repo: "rethunk-github-mcp",
69+
name: "Check with Details",
70+
headSha: "def1234567890123456789012345678901234567",
71+
status: "in_progress",
72+
title: "Test Title",
73+
summary: "Test summary content",
74+
});
75+
const parsed = JSON.parse(text) as { error?: { code: string }; id?: number };
76+
77+
if (!parsed.error && parsed.id !== undefined) {
78+
expect(typeof parsed.id).toBe("number");
79+
}
80+
});
81+
82+
test("accepts completed status with conclusion", async () => {
83+
const text = await run({
84+
owner: "Rethunk-AI",
85+
repo: "rethunk-github-mcp",
86+
name: "Completed Check",
87+
headSha: "ghi1234567890123456789012345678901234567",
88+
status: "completed",
89+
conclusion: "success",
90+
title: "All good",
91+
summary: "Check completed successfully",
92+
});
93+
const parsed = JSON.parse(text) as { error?: { code: string }; id?: number };
94+
95+
if (!parsed.error && parsed.id !== undefined) {
96+
expect(typeof parsed.id).toBe("number");
97+
}
98+
});
99+
100+
test("defaults to queued status", async () => {
101+
const text = await run({
102+
owner: "Rethunk-AI",
103+
repo: "rethunk-github-mcp",
104+
name: "Default Status Check",
105+
headSha: "jkl1234567890123456789012345678901234567",
106+
});
107+
const parsed = JSON.parse(text) as { error?: { code: string }; id?: number };
108+
109+
if (!parsed.error && parsed.id !== undefined) {
110+
expect(typeof parsed.id).toBe("number");
111+
}
112+
});
113+
});

0 commit comments

Comments
 (0)