Skip to content

Commit dc6f457

Browse files
committed
Workflow read
1 parent 0188cc0 commit dc6f457

File tree

2 files changed

+277
-0
lines changed

2 files changed

+277
-0
lines changed

pkg/github/actions.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,127 @@ const (
2222
DescriptionRepositoryName = "Repository name"
2323
)
2424

25+
type actionsResource int
26+
27+
const (
28+
actionsResourceUnknown actionsResource = iota
29+
actionsResourceWorkflow
30+
actionsResourceWorkflowRun
31+
actionsResourceWorkflowJob
32+
)
33+
34+
func (r actionsResource) String() string {
35+
switch r {
36+
case actionsResourceUnknown:
37+
return "unknown"
38+
case actionsResourceWorkflow:
39+
return "workflow"
40+
case actionsResourceWorkflowRun:
41+
return "workflow_run"
42+
case actionsResourceWorkflowJob:
43+
return "workflow_job"
44+
}
45+
return "unknown"
46+
}
47+
48+
func ActionsResourceFromString(s string) actionsResource {
49+
switch strings.ToLower(s) {
50+
case "workflow":
51+
return actionsResourceWorkflow
52+
case "workflow_run":
53+
return actionsResourceWorkflowRun
54+
case "workflow_job":
55+
return actionsResourceWorkflowJob
56+
default:
57+
return actionsResourceUnknown
58+
}
59+
}
60+
61+
func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
62+
return mcp.NewTool("actions_resource_read",
63+
mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", "Tools for reading GitHub Actions resources")),
64+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
65+
Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Read GitHub Actions"),
66+
ReadOnlyHint: ToBoolPtr(true),
67+
}),
68+
mcp.WithString("resource",
69+
mcp.Required(),
70+
mcp.Description("The type of Actions resource to read"),
71+
mcp.Enum(
72+
actionsResourceWorkflow.String(),
73+
actionsResourceWorkflowRun.String(),
74+
actionsResourceWorkflowJob.String(),
75+
),
76+
),
77+
mcp.WithString("owner",
78+
mcp.Required(),
79+
mcp.Description(DescriptionRepositoryOwner),
80+
),
81+
mcp.WithString("repo",
82+
mcp.Required(),
83+
mcp.Description(DescriptionRepositoryName),
84+
),
85+
mcp.WithNumber("resource_id",
86+
mcp.Required(),
87+
mcp.Description("The unique identifier of the resource"),
88+
),
89+
),
90+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
91+
owner, err := RequiredParam[string](request, "owner")
92+
if err != nil {
93+
return mcp.NewToolResultError(err.Error()), nil
94+
}
95+
repo, err := RequiredParam[string](request, "repo")
96+
if err != nil {
97+
return mcp.NewToolResultError(err.Error()), nil
98+
}
99+
resourceTypeStr, err := RequiredParam[string](request, "resource")
100+
if err != nil {
101+
return mcp.NewToolResultError(err.Error()), nil
102+
}
103+
resourceType := ActionsResourceFromString(resourceTypeStr)
104+
105+
resourceIDInt, err := RequiredInt(request, "resource_id")
106+
if err != nil {
107+
return mcp.NewToolResultError(err.Error()), nil
108+
}
109+
110+
client, err := getClient(ctx)
111+
if err != nil {
112+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
113+
}
114+
115+
switch resourceType {
116+
case actionsResourceWorkflow:
117+
return getActionsResourceWorkflow(ctx, client, owner, repo, int64(resourceIDInt))
118+
case actionsResourceWorkflowRun:
119+
return nil, fmt.Errorf("get workflow run by ID not implemented yet")
120+
case actionsResourceWorkflowJob:
121+
return nil, fmt.Errorf("get workflow job by ID not implemented yet")
122+
case actionsResourceUnknown:
123+
return mcp.NewToolResultError(fmt.Sprintf("unknown resource type: %s", resourceTypeStr)), nil
124+
default:
125+
// Should not reach here
126+
return mcp.NewToolResultError("unhandled resource type"), nil
127+
}
128+
}
129+
}
130+
131+
func getActionsResourceWorkflow(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) {
132+
workflow, resp, err := client.Actions.GetWorkflowByID(ctx, owner, repo, resourceID)
133+
if err != nil {
134+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil
135+
}
136+
137+
defer func() { _ = resp.Body.Close() }()
138+
r, err := json.Marshal(workflow)
139+
if err != nil {
140+
return nil, fmt.Errorf("failed to marshal workflow: %w", err)
141+
}
142+
143+
return mcp.NewToolResultText(string(r)), nil
144+
}
145+
25146
// ListWorkflows creates a tool to list workflows in a repository
26147
func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
27148
return mcp.NewTool("list_workflows",

pkg/github/actions_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,3 +1319,159 @@ func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) {
13191319
t.Logf("Sliding window: %s", profile1.String())
13201320
t.Logf("No window: %s", profile2.String())
13211321
}
1322+
1323+
func Test_ActionsRead_Workflow(t *testing.T) {
1324+
// Verify tool definition once
1325+
mockClient := github.NewClient(nil)
1326+
tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
1327+
1328+
assert.Equal(t, "actions_resource_read", tool.Name)
1329+
assert.NotEmpty(t, tool.Description)
1330+
assert.Contains(t, tool.InputSchema.Properties, "resource")
1331+
assert.Contains(t, tool.InputSchema.Properties, "owner")
1332+
assert.Contains(t, tool.InputSchema.Properties, "repo")
1333+
assert.Contains(t, tool.InputSchema.Properties, "resource_id")
1334+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"})
1335+
1336+
tests := []struct {
1337+
name string
1338+
mockedClient *http.Client
1339+
requestArgs map[string]any
1340+
expectError bool
1341+
expectedErrMsg string
1342+
}{
1343+
{
1344+
name: "missing required parameter resource",
1345+
mockedClient: mock.NewMockedHTTPClient(),
1346+
requestArgs: map[string]any{
1347+
"owner": "owner",
1348+
"repo": "repo",
1349+
"resource_id": float64(123),
1350+
},
1351+
expectError: true,
1352+
expectedErrMsg: "missing required parameter: resource",
1353+
},
1354+
{
1355+
name: "missing required parameter owner",
1356+
mockedClient: mock.NewMockedHTTPClient(),
1357+
requestArgs: map[string]any{
1358+
"resource": "workflow",
1359+
"repo": "repo",
1360+
"resource_id": float64(123),
1361+
},
1362+
expectError: true,
1363+
expectedErrMsg: "missing required parameter: owner",
1364+
},
1365+
{
1366+
name: "missing required parameter repo",
1367+
mockedClient: mock.NewMockedHTTPClient(),
1368+
requestArgs: map[string]any{
1369+
"resource": "workflow",
1370+
"owner": "owner",
1371+
"resource_id": float64(123),
1372+
},
1373+
expectError: true,
1374+
expectedErrMsg: "missing required parameter: repo",
1375+
},
1376+
{
1377+
name: "missing required parameter resource_id",
1378+
mockedClient: mock.NewMockedHTTPClient(),
1379+
requestArgs: map[string]any{
1380+
"owner": "owner",
1381+
"repo": "repo",
1382+
"resource": "workflow",
1383+
},
1384+
expectError: true,
1385+
expectedErrMsg: "missing required parameter: resource_id",
1386+
},
1387+
{
1388+
name: "unknown resource",
1389+
mockedClient: mock.NewMockedHTTPClient(),
1390+
requestArgs: map[string]any{
1391+
"resource": "random",
1392+
"owner": "owner",
1393+
"repo": "repo",
1394+
"resource_id": float64(123),
1395+
},
1396+
expectError: true,
1397+
expectedErrMsg: "unknown resource type: random",
1398+
},
1399+
{
1400+
name: "successful workflow read",
1401+
mockedClient: mock.NewMockedHTTPClient(
1402+
mock.WithRequestMatchHandler(
1403+
mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId,
1404+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1405+
workflow := &github.Workflow{
1406+
ID: github.Ptr(int64(1)),
1407+
NodeID: github.Ptr("W_1"),
1408+
Name: github.Ptr("CI"),
1409+
Path: github.Ptr(".github/workflows/test.yaml"),
1410+
State: github.Ptr("active"),
1411+
}
1412+
w.WriteHeader(http.StatusOK)
1413+
_ = json.NewEncoder(w).Encode(workflow)
1414+
}),
1415+
),
1416+
),
1417+
requestArgs: map[string]any{
1418+
"resource": "workflow",
1419+
"owner": "owner",
1420+
"repo": "repo",
1421+
"resource_id": float64(1),
1422+
},
1423+
expectError: false,
1424+
},
1425+
{
1426+
name: "missing workflow read",
1427+
mockedClient: mock.NewMockedHTTPClient(
1428+
mock.WithRequestMatchHandler(
1429+
mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId,
1430+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1431+
w.WriteHeader(http.StatusNotFound)
1432+
}),
1433+
),
1434+
),
1435+
requestArgs: map[string]any{
1436+
"resource": "workflow",
1437+
"owner": "owner",
1438+
"repo": "repo",
1439+
"resource_id": float64(2),
1440+
},
1441+
expectError: false,
1442+
},
1443+
}
1444+
1445+
for _, tc := range tests {
1446+
t.Run(tc.name, func(t *testing.T) {
1447+
// Setup client with mock
1448+
client := github.NewClient(tc.mockedClient)
1449+
_, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper)
1450+
1451+
// Create call request
1452+
request := createMCPRequest(tc.requestArgs)
1453+
1454+
// Call handler
1455+
result, err := handler(context.Background(), request)
1456+
1457+
require.NoError(t, err)
1458+
require.Equal(t, tc.expectError, result.IsError)
1459+
1460+
// Parse the result and get the text content if no error
1461+
textContent := getTextResult(t, result)
1462+
1463+
if tc.expectedErrMsg != "" {
1464+
assert.Equal(t, tc.expectedErrMsg, textContent.Text)
1465+
return
1466+
}
1467+
1468+
// Unmarshal and verify the result
1469+
var response github.Workflow
1470+
err = json.Unmarshal([]byte(textContent.Text), &response)
1471+
require.NoError(t, err)
1472+
assert.NotNil(t, response.ID)
1473+
assert.NotNil(t, response.Name)
1474+
assert.NotNil(t, response.Path)
1475+
})
1476+
}
1477+
}

0 commit comments

Comments
 (0)