Skip to content

Commit cfdcd9d

Browse files
committed
Add list deploys tool
1 parent a6b38c8 commit cfdcd9d

File tree

6 files changed

+314
-1
lines changed

6 files changed

+314
-1
lines changed

apps/webapp/app/routes/api.v1.deployments.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import {
3+
ApiDeploymentListSearchParams,
34
InitializeDeploymentRequestBody,
45
InitializeDeploymentResponseBody,
56
} from "@trigger.dev/core/v3";
7+
import { $replica } from "~/db.server";
68
import { authenticateApiRequest } from "~/services/apiAuth.server";
79
import { logger } from "~/services/logger.server";
10+
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
811
import { ServiceValidationError } from "~/v3/services/baseService.server";
912
import { InitializeDeploymentService } from "~/v3/services/initializeDeployment.server";
1013

@@ -60,3 +63,117 @@ export async function action({ request, params }: ActionFunctionArgs) {
6063
}
6164
}
6265
}
66+
67+
export const loader = createLoaderApiRoute(
68+
{
69+
searchParams: ApiDeploymentListSearchParams,
70+
allowJWT: true,
71+
corsStrategy: "none",
72+
authorization: {
73+
action: "read",
74+
resource: () => ({ deployments: "list" }),
75+
superScopes: ["read:deployments", "read:all", "admin"],
76+
},
77+
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
78+
},
79+
async ({ searchParams, authentication }) => {
80+
const limit = Math.max(Math.min(searchParams["page[size]"] ?? 20, 100), 5);
81+
82+
const afterDeployment = searchParams["page[after]"]
83+
? await $replica.workerDeployment.findFirst({
84+
where: {
85+
friendlyId: searchParams["page[after]"],
86+
environmentId: authentication.environment.id,
87+
},
88+
})
89+
: undefined;
90+
91+
const deployments = await $replica.workerDeployment.findMany({
92+
where: {
93+
environmentId: authentication.environment.id,
94+
...(afterDeployment ? { id: { lt: afterDeployment.id } } : {}),
95+
...getCreatedAtFilter(searchParams),
96+
...(searchParams.status ? { status: searchParams.status } : {}),
97+
},
98+
orderBy: {
99+
id: "desc",
100+
},
101+
take: limit + 1,
102+
});
103+
104+
const hasMore = deployments.length > limit;
105+
const nextCursor = hasMore ? deployments[limit - 1].friendlyId : undefined;
106+
const data = hasMore ? deployments.slice(0, limit) : deployments;
107+
108+
return json({
109+
data: data.map((deployment) => ({
110+
id: deployment.friendlyId,
111+
createdAt: deployment.createdAt,
112+
shortCode: deployment.shortCode,
113+
version: deployment.version.toString(),
114+
runtime: deployment.runtime,
115+
runtimeVersion: deployment.runtimeVersion,
116+
status: deployment.status,
117+
deployedAt: deployment.deployedAt,
118+
git: deployment.git,
119+
error: deployment.errorData ?? undefined,
120+
})),
121+
pagination: {
122+
next: nextCursor,
123+
},
124+
});
125+
}
126+
);
127+
128+
import parseDuration from "parse-duration";
129+
130+
function getCreatedAtFilter(searchParams: ApiDeploymentListSearchParams) {
131+
if (searchParams.period) {
132+
const duration = parseDuration(searchParams.period, "ms");
133+
134+
if (!duration) {
135+
throw new ServiceValidationError(
136+
`Invalid search query parameter: period=${searchParams.period}`,
137+
400
138+
);
139+
}
140+
141+
return {
142+
createdAt: {
143+
gte: new Date(Date.now() - duration),
144+
lte: new Date(),
145+
},
146+
};
147+
}
148+
149+
if (searchParams.from && searchParams.to) {
150+
const fromDate = safeDateFromString(searchParams.from, "from");
151+
const toDate = safeDateFromString(searchParams.to, "to");
152+
153+
return {
154+
createdAt: {
155+
gte: fromDate,
156+
lte: toDate,
157+
},
158+
};
159+
}
160+
161+
if (searchParams.from) {
162+
const fromDate = safeDateFromString(searchParams.from, "from");
163+
return {
164+
createdAt: {
165+
gte: fromDate,
166+
},
167+
};
168+
}
169+
170+
return {};
171+
}
172+
173+
function safeDateFromString(value: string, paramName: string) {
174+
const date = new Date(value);
175+
if (isNaN(date.getTime())) {
176+
throw new ServiceValidationError(`Invalid search query parameter: ${paramName}=${value}`, 400);
177+
}
178+
return date;
179+
}

apps/webapp/app/services/authorization.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
22

3-
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints"] as const;
3+
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments"] as const;
44

55
export type AuthorizationResources = {
66
[key in (typeof ResourceTypes)[number]]?: string | string[];

packages/cli-v3/src/commands/mcp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
registerGetRunDetailsTool,
1717
registerGetTasksTool,
1818
registerInitializeProjectTool,
19+
registerListDeploymentsTool,
1920
registerListOrgsTool,
2021
registerListProjectsTool,
2122
registerListRunsTool,
@@ -112,6 +113,7 @@ export async function mcpCommand(options: McpCommandOptions) {
112113
registerListOrgsTool(context);
113114
registerCreateProjectTool(context);
114115
registerDeployTool(context);
116+
registerListDeploymentsTool(context);
115117

116118
await server.connect(transport);
117119
}

packages/cli-v3/src/mcp/tools.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ApiDeploymentListParams,
23
GetOrgsResponseBody,
34
GetProjectsResponseBody,
45
MachinePresetName,
@@ -994,6 +995,100 @@ export function registerDeployTool(context: McpContext) {
994995
);
995996
}
996997

998+
export function registerListDeploymentsTool(context: McpContext) {
999+
context.server.registerTool(
1000+
"list_deployments",
1001+
{
1002+
description: "List deployments for a project",
1003+
inputSchema: {
1004+
projectRef: ProjectRefSchema,
1005+
configPath: z
1006+
.string()
1007+
.describe(
1008+
"The path to the trigger.config.ts file. Only used when the trigger.config.ts file is not at the root dir (like in a monorepo setup). If not provided, we will try to find the config file in the current working directory"
1009+
)
1010+
.optional(),
1011+
environment: z
1012+
.enum(["staging", "prod", "preview"])
1013+
.describe("The environment to list deployments for")
1014+
.default("prod"),
1015+
branch: z
1016+
.string()
1017+
.describe("The branch to trigger the task in, only used for preview environments")
1018+
.optional(),
1019+
...ApiDeploymentListParams,
1020+
},
1021+
},
1022+
async ({
1023+
projectRef,
1024+
configPath,
1025+
environment,
1026+
branch,
1027+
cursor,
1028+
limit,
1029+
status,
1030+
from,
1031+
to,
1032+
period,
1033+
}) => {
1034+
context.logger?.log("calling list_deployments", {
1035+
projectRef,
1036+
configPath,
1037+
environment,
1038+
branch,
1039+
cursor,
1040+
limit,
1041+
status,
1042+
from,
1043+
to,
1044+
period,
1045+
});
1046+
1047+
const projectRefResult = await resolveExistingProjectRef(context, projectRef, configPath);
1048+
1049+
if (projectRefResult.status === "error") {
1050+
return respondWithError(projectRefResult.error);
1051+
}
1052+
1053+
const $projectRef = projectRefResult.projectRef;
1054+
1055+
context.logger?.log("list_deployments projectRefResult", { projectRefResult });
1056+
1057+
const auth = await mcpAuth({
1058+
server: context.server,
1059+
defaultApiUrl: context.options.apiUrl,
1060+
profile: context.options.profile,
1061+
context,
1062+
});
1063+
1064+
if (!auth.ok) {
1065+
return respondWithError(auth.error);
1066+
}
1067+
1068+
const apiClient = await createApiClientWithPublicJWT(auth, $projectRef, environment, [
1069+
"read:deployments",
1070+
]);
1071+
1072+
if (!apiClient) {
1073+
return respondWithError("Failed to create API client with public JWT");
1074+
}
1075+
1076+
const result = await apiClient.listDeployments({
1077+
cursor: cursor,
1078+
limit,
1079+
status,
1080+
from,
1081+
to,
1082+
period,
1083+
});
1084+
1085+
return {
1086+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1087+
};
1088+
}
1089+
);
1090+
}
1091+
9971092
async function resolveCLIExec(context: McpContext, cwd: string): Promise<[string, string]> {
9981093
// Lets first try to get the version of the CLI package
9991094
const installedCLI = await tryResolveTriggerCLIPath(context, cwd);

packages/core/src/v3/apiClient/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { VERSION } from "../../version.js";
33
import { generateJWT } from "../jwt.js";
44
import {
55
AddTagsRequestBody,
6+
ApiDeploymentListOptions,
7+
ApiDeploymentListResponseItem,
8+
ApiDeploymentListSearchParams,
69
BatchTaskRunExecutionResult,
710
BatchTriggerTaskV3RequestBody,
811
BatchTriggerTaskV3Response,
@@ -972,6 +975,41 @@ export class ApiClient {
972975
);
973976
}
974977

978+
listDeployments(options?: ApiDeploymentListOptions, requestOptions?: ZodFetchOptions) {
979+
const searchParams = new URLSearchParams();
980+
981+
if (options?.status) {
982+
searchParams.append("status", options.status);
983+
}
984+
985+
if (options?.period) {
986+
searchParams.append("period", options.period);
987+
}
988+
989+
if (options?.from) {
990+
searchParams.append("from", options.from);
991+
}
992+
993+
if (options?.to) {
994+
searchParams.append("to", options.to);
995+
}
996+
997+
return zodfetchCursorPage(
998+
ApiDeploymentListResponseItem,
999+
`${this.baseUrl}/api/v1/deployments`,
1000+
{
1001+
query: searchParams,
1002+
after: options?.cursor,
1003+
limit: options?.limit,
1004+
},
1005+
{
1006+
method: "GET",
1007+
headers: this.#getHeaders(false),
1008+
},
1009+
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
1010+
);
1011+
}
1012+
9751013
async fetchStream<T>(
9761014
runId: string,
9771015
streamKey: string,

packages/core/src/v3/schemas/api.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,3 +1139,64 @@ export function timeoutError(timeout: Date) {
11391139
message: `Waitpoint timed out at ${timeout.toISOString()}`,
11401140
};
11411141
}
1142+
1143+
const ApiDeploymentCommonShape = {
1144+
from: z.string().describe("The date to start the search from, in ISO 8601 format").optional(),
1145+
to: z.string().describe("The date to end the search, in ISO 8601 format").optional(),
1146+
period: z.string().describe("The period to search within (e.g. 1d, 7d, 3h, etc.)").optional(),
1147+
status: z
1148+
.enum(["PENDING", "BUILDING", "DEPLOYING", "DEPLOYED", "FAILED", "CANCELED", "TIMED_OUT"])
1149+
.describe("Filter deployments that are in this status")
1150+
.optional(),
1151+
};
1152+
1153+
const ApiDeploymentListPaginationCursor = z
1154+
.string()
1155+
.describe("The deployment ID to start the search from, to get the next page")
1156+
.optional();
1157+
1158+
const ApiDeploymentListPaginationLimit = z.coerce
1159+
.number()
1160+
.describe("The number of deployments to return, defaults to 20 (max 100)")
1161+
.optional();
1162+
1163+
export const ApiDeploymentListParams = {
1164+
...ApiDeploymentCommonShape,
1165+
cursor: ApiDeploymentListPaginationCursor,
1166+
limit: ApiDeploymentListPaginationLimit,
1167+
};
1168+
1169+
export const ApiDeploymentListOptions = z.object(ApiDeploymentListParams);
1170+
1171+
export type ApiDeploymentListOptions = z.infer<typeof ApiDeploymentListOptions>;
1172+
1173+
export const ApiDeploymentListSearchParams = z.object({
1174+
...ApiDeploymentCommonShape,
1175+
"page[after]": ApiDeploymentListPaginationCursor,
1176+
"page[size]": ApiDeploymentListPaginationLimit,
1177+
});
1178+
1179+
export type ApiDeploymentListSearchParams = z.infer<typeof ApiDeploymentListSearchParams>;
1180+
1181+
export const ApiDeploymentListResponseItem = z.object({
1182+
id: z.string(),
1183+
createdAt: z.coerce.date(),
1184+
shortCode: z.string(),
1185+
version: z.string(),
1186+
runtime: z.string(),
1187+
runtimeVersion: z.string(),
1188+
status: z.enum([
1189+
"PENDING",
1190+
"BUILDING",
1191+
"DEPLOYING",
1192+
"DEPLOYED",
1193+
"FAILED",
1194+
"CANCELED",
1195+
"TIMED_OUT",
1196+
]),
1197+
deployedAt: z.coerce.date().optional(),
1198+
git: z.record(z.any()).optional(),
1199+
error: DeploymentErrorData.optional(),
1200+
});
1201+
1202+
export type ApiDeploymentListResponseItem = z.infer<typeof ApiDeploymentListResponseItem>;

0 commit comments

Comments
 (0)