import type { JsonObject } from "type-fest" import * as z from "zod" import { defineAction } from "../../../automation/actions" import { getLinearApi } from "@automate.ax/integration-contracts/linear" import { LINEAR_PROJECT_FIELDS } from "../lib/fragments" import { LINEAR_PAGE_INFO_SCHEMA, LINEAR_PAGE_INPUT_SCHEMA, LINEAR_PROJECT_SCHEMA, LINEAR_PROJECT_STATUS_SCHEMA, LINEAR_PROVIDER_PROJECT_SCHEMA, linearConnectionSchema, toLinearProject, } from "@automate.ax/integration-contracts/linear" import { LINEAR_READ_ACCOUNT_OPTIONS, LINEAR_WRITE_ACCOUNT_OPTIONS, } from "../lib/scopes" const PROJECT_RESPONSE_SCHEMA = z.object({ project: LINEAR_PROVIDER_PROJECT_SCHEMA, }) const PROJECT_CONNECTION_SCHEMA = linearConnectionSchema( LINEAR_PROVIDER_PROJECT_SCHEMA, ) const PROJECT_PAGE_SCHEMA = z.object({ pageInfo: LINEAR_PAGE_INFO_SCHEMA, projects: LINEAR_PROJECT_SCHEMA.array(), }) const PROJECT_MUTATION_RESPONSE_SCHEMA = z.object({ projectMutation: z.object({ project: LINEAR_PROVIDER_PROJECT_SCHEMA, success: z.literal(true), }), }) const PROJECT_ARCHIVE_RESPONSE_SCHEMA = z.object({ projectMutation: z.object({ entity: LINEAR_PROVIDER_PROJECT_SCHEMA, success: z.literal(true), }), }) const PROJECT_PRIORITY_SCHEMA = z.number().int().min(0).max(4) const PROJECT_CREATE_INPUT_SCHEMA = z.object({ /** Project display color, such as a hex value. */ color: z.string().min(1).optional(), /** Long-form Markdown project content. */ content: z.string().optional(), /** Short project summary. */ description: z.string().optional(), /** Emoji or provider-supported project icon value. */ icon: z.string().min(1).optional(), /** Workspace user leading the project. */ leadId: z.string().min(1).optional(), /** Initial project members. */ memberIds: z.string().min(1).array().optional(), /** Project name. */ name: z.string().min(1), /** Priority from 0 (no priority) to 4 (low). */ priority: PROJECT_PRIORITY_SCHEMA.optional(), /** Project start date in `YYYY-MM-DD` format. */ startDate: z.iso.date().optional(), /** Legacy project state; prefer `statusId` in modern workspaces. */ state: z.string().min(1).optional(), /** Initial project status ID. */ statusId: z.string().min(1).optional(), /** Project target date in `YYYY-MM-DD` format. */ targetDate: z.iso.date().optional(), /** Teams that can access and own issues in the project. */ teamIds: z.string().min(1).array().min(1), }) const PROJECT_UPDATE_FIELDS_SCHEMA = z.object({ /** Replace the project display color. */ color: z.string().min(1).optional(), /** Replace long-form Markdown content, or pass `null` to clear it. */ content: z.string().nullable().optional(), /** Replace the short project summary. */ description: z.string().optional(), /** Replace the project icon, or pass `null` to clear it. */ icon: z.string().min(1).nullable().optional(), /** Replace the project lead, or pass `null` to remove one. */ leadId: z.string().min(1).nullable().optional(), /** Replace the complete project member set. */ memberIds: z.string().min(1).array().optional(), /** Replace the project name. */ name: z.string().min(1).optional(), /** Priority from 0 (no priority) to 4 (low). */ priority: PROJECT_PRIORITY_SCHEMA.optional(), /** Replace the start date, or pass `null` to clear it. */ startDate: z.iso.date().nullable().optional(), /** Replace the legacy project state. */ state: z.string().min(1).optional(), /** Move the project to another status. */ statusId: z.string().min(1).optional(), /** Replace the target date, or pass `null` to clear it. */ targetDate: z.iso.date().nullable().optional(), /** Replace the complete set of project teams. */ teamIds: z.string().min(1).array().min(1).optional(), }) /** Lists Linear projects with common filters and Relay pagination. */ export const listLinearProjects = defineAction("List Linear projects") .describe("Lists a cursor-paginated page of filtered Linear projects.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( LINEAR_PAGE_INPUT_SCHEMA.extend({ /** Team that must be able to access returned projects. */ accessibleTeamId: z.string().min(1).optional(), /** Workspace user leading returned projects. */ leadId: z.string().min(1).optional(), /** Workspace user belonging to returned projects. */ memberId: z.string().min(1).optional(), /** Sort by creation time or most recent update. */ orderBy: z.enum(["createdAt", "updatedAt"]).prefault("updatedAt"), /** Text contained in Linear's searchable project content. */ search: z.string().min(1).optional(), /** Legacy project state. */ state: z.string().min(1).optional(), /** Project status ID. */ statusId: z.string().min(1).optional(), }), ) .output(PROJECT_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const filter = projectFilter(input) const { projects } = await getLinearApi(account.secret).graphql( `query LinearProjects( $first: Int! $after: String $includeArchived: Boolean $orderBy: PaginationOrderBy! $filter: ProjectFilter ) { projects( first: $first after: $after includeArchived: $includeArchived orderBy: $orderBy filter: $filter ) { nodes { ${LINEAR_PROJECT_FIELDS} } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }`, { responseSchema: z.object({ projects: PROJECT_CONNECTION_SCHEMA }), variables: { first: input.limit, orderBy: input.orderBy, ...(input.after && { after: input.after }), ...(input.includeArchived !== undefined && { includeArchived: input.includeArchived, }), ...(Object.keys(filter).length > 0 && { filter }), }, }, ) return { pageInfo: projects.pageInfo!, projects: projects.nodes.map(toLinearProject), } }) /** Gets one Linear project by UUID or slug. */ export const getLinearProject = defineAction("Get Linear project") .describe("Gets one Linear project by UUID or project slug.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( z.object({ /** Stable project UUID or slug. */ projectId: z.string().min(1), }), ) .output(LINEAR_PROJECT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getLinearApi(account.secret) .graphql( `query LinearProject($id: String!) { project(id: $id) { ${LINEAR_PROJECT_FIELDS} } }`, { responseSchema: PROJECT_RESPONSE_SCHEMA, variables: { id: input.projectId }, }, ) .then(({ project }) => toLinearProject(project)), ) /** Lists the workspace's configured Linear project statuses. */ export const listLinearProjectStatuses = defineAction( "List Linear project statuses", ) .describe("Lists project statuses configured for the Linear workspace.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .output(LINEAR_PROJECT_STATUS_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account }) => getLinearApi(account.secret) .graphql( `query LinearProjectStatuses { organization { projectStatuses { id name color description type } } }`, { responseSchema: z.object({ organization: z.object({ projectStatuses: LINEAR_PROJECT_STATUS_SCHEMA.array(), }), }), }, ) .then(({ organization }) => organization.projectStatuses), ) /** Creates a Linear project shared with one or more teams. */ export const createLinearProject = defineAction("Create Linear project") .describe("Creates a Linear project with planning and membership details.") .account("linear", LINEAR_WRITE_ACCOUNT_OPTIONS) .input(PROJECT_CREATE_INPUT_SCHEMA) .output(LINEAR_PROJECT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => mutateProject(account.secret, "projectCreate", { input }), ) /** Updates editable planning, status, ownership, and content fields. */ export const updateLinearProject = defineAction("Update Linear project") .describe("Updates a Linear project's planning, ownership, and content.") .account("linear", LINEAR_WRITE_ACCOUNT_OPTIONS) .input( PROJECT_UPDATE_FIELDS_SCHEMA.extend({ /** UUID or slug of the project to update. */ projectId: z.string().min(1), }), ) .output(LINEAR_PROJECT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { projectId, ...update } = input return mutateProject(account.secret, "projectUpdate", { id: projectId, input: update, }) }) /** Archives a Linear project or moves it to recently deleted. */ export const archiveLinearProject = defineAction("Archive Linear project") .describe("Archives a Linear project, optionally moving it to trash.") .account("linear", LINEAR_WRITE_ACCOUNT_OPTIONS) .input( z.object({ /** UUID or slug of the project to archive. */ projectId: z.string().min(1), /** Move the project to recently deleted instead of the archive. */ trash: z.boolean().optional(), }), ) .output(LINEAR_PROJECT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { projectMutation } = await getLinearApi(account.secret).graphql( `mutation LinearProjectArchive($id: String!, $trash: Boolean) { projectMutation: projectArchive(id: $id, trash: $trash) { success entity { ${LINEAR_PROJECT_FIELDS} } } }`, { responseSchema: PROJECT_ARCHIVE_RESPONSE_SCHEMA, variables: { id: input.projectId, ...(input.trash !== undefined && { trash: input.trash }), }, }, ) return toLinearProject(projectMutation.entity) }) /** Restores an archived Linear project. */ export const unarchiveLinearProject = defineAction("Unarchive Linear project") .describe("Restores an archived Linear project.") .account("linear", LINEAR_WRITE_ACCOUNT_OPTIONS) .input( z.object({ /** Stable project UUID or slug. */ projectId: z.string().min(1), }), ) .output(LINEAR_PROJECT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { projectMutation } = await getLinearApi(account.secret).graphql( `mutation LinearProjectUnarchive($id: String!) { projectMutation: projectUnarchive(id: $id) { success entity { ${LINEAR_PROJECT_FIELDS} } } }`, { responseSchema: PROJECT_ARCHIVE_RESPONSE_SCHEMA, variables: { id: input.projectId }, }, ) return toLinearProject(projectMutation.entity) }) /** * Executes one Linear project create or update mutation. * * @param secret - Resolved Linear integration secret. * @param mutation - Provider mutation to execute. * @param variables - GraphQL mutation variables. */ async function mutateProject( secret: Record, mutation: "projectCreate" | "projectUpdate", variables: JsonObject, ) { const { projectMutation } = await getLinearApi(secret).graphql( mutation === "projectCreate" ? `mutation LinearProjectCreate($input: ProjectCreateInput!) { projectMutation: projectCreate(input: $input) { success project { ${LINEAR_PROJECT_FIELDS} } } }` : `mutation LinearProjectUpdate($id: String!, $input: ProjectUpdateInput!) { projectMutation: projectUpdate(id: $id, input: $input) { success project { ${LINEAR_PROJECT_FIELDS} } } }`, { responseSchema: PROJECT_MUTATION_RESPONSE_SCHEMA, variables }, ) return toLinearProject(projectMutation.project) } /** * Builds a provider-native Linear project filter from public action fields. * * @param input - Validated public project filters. * @param input.accessibleTeamId - Required accessible team ID. * @param input.leadId - Required project lead ID. * @param input.memberId - Required project member ID. * @param input.search - Searchable-content substring. * @param input.state - Required legacy project state. * @param input.statusId - Required project status ID. */ function projectFilter(input: { accessibleTeamId?: string leadId?: string memberId?: string search?: string state?: string statusId?: string }): JsonObject { return { ...(input.accessibleTeamId && { accessibleTeams: { some: { id: { eq: input.accessibleTeamId } } }, }), ...(input.leadId && { lead: { id: { eq: input.leadId } } }), ...(input.memberId && { members: { some: { id: { eq: input.memberId } } }, }), ...(input.search && { searchableContent: { contains: input.search } }), ...(input.state && { state: { eq: input.state } }), ...(input.statusId && { status: { id: { eq: input.statusId } } }), } }