Skip to content

Commit 97ad00f

Browse files
authored
build_get_timeline tool (Issue 345) (#443)
Implement `build_get_timeline` tool with tests. ## GitHub issue number Fixes #345 ## **Associated Risks** ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Added tests. Tested via the following prompt: ``` Get the timeline for the buildid 6 in the project ABC ```
1 parent 43364f3 commit 97ad00f

File tree

3 files changed

+123
-0
lines changed

3 files changed

+123
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Interact with these Azure DevOps services:
120120
- **build_get_log**: Retrieve the logs for a specific build.
121121
- **build_get_log_by_id**: Get a specific build log by log ID.
122122
- **build_get_changes**: Get the changes associated with a specific build.
123+
- **build_get_timeline**: Retrieve the timeline for a specific build, showing detailed information about steps and tasks.
123124
- **build_run_build**: Trigger a new build for a specified definition.
124125
- **build_get_status**: Fetch the status of a specific build.
125126
- **build_update_build_stage**: Update the stage of a specific build.

src/tools/builds.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const BUILD_TOOLS = {
1919
run_build: "build_run_build",
2020
get_status: "build_get_status",
2121
update_build_stage: "build_update_build_stage",
22+
get_timeline: "build_get_timeline",
2223
};
2324

2425
function configureBuildTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
@@ -357,6 +358,27 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
357358
};
358359
}
359360
);
361+
362+
server.tool(
363+
BUILD_TOOLS.get_timeline,
364+
"Retrieves the timeline for a specific build, showing detailed information about steps and tasks.",
365+
{
366+
project: z.string().describe("Project ID or name to get the build timeline for"),
367+
buildId: z.number().describe("ID of the build to get the timeline for"),
368+
timelineId: z.string().optional().describe("The ID of a specific timeline to retrieve. If not specified, the primary timeline is returned."),
369+
changeId: z.number().optional().describe("If specified, only includes timeline records that changed after this watermark."),
370+
planId: z.string().optional().describe("The ID of the plan to retrieve the timeline for."),
371+
},
372+
async ({ project, buildId, timelineId, changeId, planId }) => {
373+
const connection = await connectionProvider();
374+
const buildApi = await connection.getBuildApi();
375+
const timeline = await buildApi.getBuildTimeline(project, buildId, timelineId, changeId, planId);
376+
377+
return {
378+
content: [{ type: "text", text: JSON.stringify(timeline, null, 2) }],
379+
};
380+
}
381+
);
360382
}
361383

362384
export { BUILD_TOOLS, configureBuildTools };

test/src/tools/builds.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,4 +871,104 @@ describe("configureBuildTools", () => {
871871
await expect(handler(params)).rejects.toThrow("Build not found");
872872
});
873873
});
874+
875+
describe("get_timeline tool", () => {
876+
it("should retrieve the timeline for a build", async () => {
877+
configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
878+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_get_timeline");
879+
if (!call) throw new Error("build_get_timeline tool not registered");
880+
const [, , , handler] = call;
881+
882+
const mockTimeline = {
883+
id: "timeline-123",
884+
records: [
885+
{
886+
id: "record-1",
887+
name: "Initialize job",
888+
type: "Task",
889+
startTime: "2024-08-19T10:00:00.000Z",
890+
finishTime: "2024-08-19T10:01:00.000Z",
891+
state: "Completed",
892+
result: "Succeeded",
893+
},
894+
{
895+
id: "record-2",
896+
name: "Checkout",
897+
type: "Task",
898+
startTime: "2024-08-19T10:01:00.000Z",
899+
finishTime: "2024-08-19T10:02:00.000Z",
900+
state: "Completed",
901+
result: "Succeeded",
902+
},
903+
],
904+
};
905+
906+
const mockBuildApi = {
907+
getBuildTimeline: jest.fn().mockResolvedValue(mockTimeline),
908+
};
909+
mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
910+
911+
const params = {
912+
project: "test-project",
913+
buildId: 123,
914+
timelineId: "timeline-123",
915+
changeId: 456,
916+
planId: "plan-789",
917+
};
918+
919+
const result = await handler(params);
920+
921+
expect(mockBuildApi.getBuildTimeline).toHaveBeenCalledWith("test-project", 123, "timeline-123", 456, "plan-789");
922+
expect(result.content[0].text).toBe(JSON.stringify(mockTimeline, null, 2));
923+
});
924+
925+
it("should handle minimal parameters for get_timeline", async () => {
926+
configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
927+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_get_timeline");
928+
if (!call) throw new Error("build_get_timeline tool not registered");
929+
const [, , , handler] = call;
930+
931+
const mockTimeline = {
932+
id: "timeline-123",
933+
records: [
934+
{ id: "record-1", name: "Task 1", state: "Completed" },
935+
{ id: "record-2", name: "Task 2", state: "Completed" },
936+
],
937+
};
938+
939+
const mockBuildApi = {
940+
getBuildTimeline: jest.fn().mockResolvedValue(mockTimeline),
941+
};
942+
mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
943+
944+
const params = {
945+
project: "test-project",
946+
buildId: 123,
947+
};
948+
949+
const result = await handler(params);
950+
951+
expect(mockBuildApi.getBuildTimeline).toHaveBeenCalledWith("test-project", 123, undefined, undefined, undefined);
952+
expect(result.content[0].text).toBe(JSON.stringify(mockTimeline, null, 2));
953+
});
954+
955+
it("should handle API errors for get_timeline", async () => {
956+
configureBuildTools(server, tokenProvider, connectionProvider, userAgentProvider);
957+
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "build_get_timeline");
958+
if (!call) throw new Error("build_get_timeline tool not registered");
959+
const [, , , handler] = call;
960+
961+
const mockBuildApi = {
962+
getBuildTimeline: jest.fn().mockRejectedValue(new Error("Timeline not found")),
963+
};
964+
mockConnection.getBuildApi.mockResolvedValue(mockBuildApi);
965+
966+
const params = {
967+
project: "test-project",
968+
buildId: 999,
969+
};
970+
971+
await expect(handler(params)).rejects.toThrow("Timeline not found");
972+
});
973+
});
874974
});

0 commit comments

Comments
 (0)