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_ISSUE_LABEL_FIELDS, LINEAR_TEAM_FIELDS, LINEAR_USER_FIELDS, LINEAR_WORKFLOW_STATE_FIELDS, } from "../lib/fragments" import { LINEAR_ISSUE_LABEL_SCHEMA, LINEAR_PAGE_INFO_SCHEMA, LINEAR_PAGE_INPUT_SCHEMA, LINEAR_TEAM_SCHEMA, LINEAR_USER_SCHEMA, LINEAR_WORKFLOW_STATE_SCHEMA, linearConnectionSchema, } from "@automate.ax/integration-contracts/linear" import { LINEAR_READ_ACCOUNT_OPTIONS } from "../lib/scopes" const TEAM_RESPONSE_SCHEMA = z.object({ team: LINEAR_TEAM_SCHEMA }) const TEAM_CONNECTION_SCHEMA = linearConnectionSchema(LINEAR_TEAM_SCHEMA) const TEAM_PAGE_SCHEMA = z.object({ pageInfo: LINEAR_PAGE_INFO_SCHEMA, teams: LINEAR_TEAM_SCHEMA.array(), }) const USER_RESPONSE_SCHEMA = z.object({ user: LINEAR_USER_SCHEMA }) const USER_CONNECTION_SCHEMA = linearConnectionSchema(LINEAR_USER_SCHEMA) const USER_PAGE_SCHEMA = z.object({ pageInfo: LINEAR_PAGE_INFO_SCHEMA, users: LINEAR_USER_SCHEMA.array(), }) const WORKFLOW_STATE_RESPONSE_SCHEMA = z.object({ workflowState: LINEAR_WORKFLOW_STATE_SCHEMA, }) const WORKFLOW_STATE_CONNECTION_SCHEMA = linearConnectionSchema( LINEAR_WORKFLOW_STATE_SCHEMA, ) const WORKFLOW_STATE_PAGE_SCHEMA = z.object({ pageInfo: LINEAR_PAGE_INFO_SCHEMA, workflowStates: LINEAR_WORKFLOW_STATE_SCHEMA.array(), }) const ISSUE_LABEL_RESPONSE_SCHEMA = z.object({ issueLabel: LINEAR_ISSUE_LABEL_SCHEMA, }) const ISSUE_LABEL_CONNECTION_SCHEMA = linearConnectionSchema( LINEAR_ISSUE_LABEL_SCHEMA, ) const ISSUE_LABEL_PAGE_SCHEMA = z.object({ issueLabels: LINEAR_ISSUE_LABEL_SCHEMA.array(), pageInfo: LINEAR_PAGE_INFO_SCHEMA, }) /** Lists teams visible to the connected Linear user. */ export const listLinearTeams = defineAction("List Linear teams") .describe("Lists a cursor-paginated page of Linear teams.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input(LINEAR_PAGE_INPUT_SCHEMA) .output(TEAM_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { teams } = await getLinearApi(account.secret).graphql( `query LinearTeams($first: Int!, $after: String, $includeArchived: Boolean) { teams(first: $first, after: $after, includeArchived: $includeArchived) { nodes { ${LINEAR_TEAM_FIELDS} } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }`, { responseSchema: z.object({ teams: TEAM_CONNECTION_SCHEMA }), variables: pageVariables(input), }, ) return { pageInfo: teams.pageInfo!, teams: teams.nodes } }) /** Gets one Linear team by ID or key. */ export const getLinearTeam = defineAction("Get Linear team") .describe("Gets one Linear team by its ID or issue key.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( z.object({ /** Stable Linear team ID or issue key. */ teamId: z.string().min(1), }), ) .output(LINEAR_TEAM_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getLinearApi(account.secret) .graphql( `query LinearTeam($id: String!) { team(id: $id) { ${LINEAR_TEAM_FIELDS} } }`, { responseSchema: TEAM_RESPONSE_SCHEMA, variables: { id: input.teamId }, }, ) .then(({ team }) => team), ) /** Lists Linear workspace users with optional active-user and text filters. */ export const listLinearUsers = defineAction("List Linear users") .describe("Lists a cursor-paginated page of Linear workspace users.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( LINEAR_PAGE_INPUT_SCHEMA.extend({ /** Limit results to active or disabled users. */ active: z.boolean().optional(), /** Limit results to app users or human workspace members. */ app: z.boolean().optional(), /** Exact workspace email address. */ email: z.string().min(1).optional(), /** Include disabled users in the candidate set. */ includeDisabled: z.boolean().optional(), /** Case-insensitive substring matched against the user's name. */ name: z.string().min(1).optional(), }), ) .output(USER_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const filter = { ...(input.active !== undefined && { active: { eq: input.active } }), ...(input.app !== undefined && { app: { eq: input.app } }), ...(input.email && { email: { eqIgnoreCase: input.email } }), ...(input.name && { name: { containsIgnoreCase: input.name } }), } const { users } = await getLinearApi(account.secret).graphql( `query LinearUsers( $first: Int! $after: String $includeArchived: Boolean $includeDisabled: Boolean $filter: UserFilter ) { users( first: $first after: $after includeArchived: $includeArchived includeDisabled: $includeDisabled filter: $filter ) { nodes { ${LINEAR_USER_FIELDS} } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }`, { responseSchema: z.object({ users: USER_CONNECTION_SCHEMA }), variables: { ...pageVariables(input), ...(input.includeDisabled !== undefined && { includeDisabled: input.includeDisabled, }), ...(Object.keys(filter).length > 0 && { filter }), }, }, ) return { pageInfo: users.pageInfo!, users: users.nodes } }) /** Gets one Linear workspace user by ID. */ export const getLinearUser = defineAction("Get Linear user") .describe("Gets one Linear workspace user by ID.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( z.object({ /** Stable Linear user ID. */ userId: z.string().min(1), }), ) .output(LINEAR_USER_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getLinearApi(account.secret) .graphql( `query LinearUser($id: String!) { user(id: $id) { ${LINEAR_USER_FIELDS} } }`, { responseSchema: USER_RESPONSE_SCHEMA, variables: { id: input.userId }, }, ) .then(({ user }) => user), ) /** Lists workflow states, optionally limited to one Linear team. */ export const listLinearWorkflowStates = defineAction( "List Linear workflow states", ) .describe("Lists a cursor-paginated page of Linear workflow states.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( LINEAR_PAGE_INPUT_SCHEMA.extend({ /** Team whose workflow states should be returned. */ teamId: z.string().min(1).optional(), }), ) .output(WORKFLOW_STATE_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { workflowStates } = await getLinearApi(account.secret).graphql( `query LinearWorkflowStates( $first: Int! $after: String $includeArchived: Boolean $filter: WorkflowStateFilter ) { workflowStates( first: $first after: $after includeArchived: $includeArchived filter: $filter ) { nodes { ${LINEAR_WORKFLOW_STATE_FIELDS} } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }`, { responseSchema: z.object({ workflowStates: WORKFLOW_STATE_CONNECTION_SCHEMA, }), variables: { ...pageVariables(input), ...(input.teamId && { filter: { team: { id: { eq: input.teamId } } }, }), }, }, ) return { pageInfo: workflowStates.pageInfo!, workflowStates: workflowStates.nodes, } }) /** Gets one Linear workflow state by ID. */ export const getLinearWorkflowState = defineAction("Get Linear workflow state") .describe("Gets one Linear workflow state by ID.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( z.object({ /** Stable Linear workflow state ID. */ workflowStateId: z.string().min(1), }), ) .output(LINEAR_WORKFLOW_STATE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getLinearApi(account.secret) .graphql( `query LinearWorkflowState($id: String!) { workflowState(id: $id) { ${LINEAR_WORKFLOW_STATE_FIELDS} } }`, { responseSchema: WORKFLOW_STATE_RESPONSE_SCHEMA, variables: { id: input.workflowStateId }, }, ) .then(({ workflowState }) => workflowState), ) /** Lists workspace and team-specific Linear issue labels. */ export const listLinearIssueLabels = defineAction("List Linear issue labels") .describe("Lists a cursor-paginated page of Linear issue labels.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( LINEAR_PAGE_INPUT_SCHEMA.extend({ /** Case-insensitive label-name substring. */ name: z.string().min(1).optional(), /** Team whose labels should be returned. */ teamId: z.string().min(1).optional(), }), ) .output(ISSUE_LABEL_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const filter = { ...(input.name && { name: { containsIgnoreCase: input.name } }), ...(input.teamId && { team: { id: { eq: input.teamId } } }), } const { issueLabels } = await getLinearApi(account.secret).graphql( `query LinearIssueLabels( $first: Int! $after: String $includeArchived: Boolean $filter: IssueLabelFilter ) { issueLabels( first: $first after: $after includeArchived: $includeArchived filter: $filter ) { nodes { ${LINEAR_ISSUE_LABEL_FIELDS} } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }`, { responseSchema: z.object({ issueLabels: ISSUE_LABEL_CONNECTION_SCHEMA, }), variables: { ...pageVariables(input), ...(Object.keys(filter).length > 0 && { filter }), }, }, ) return { issueLabels: issueLabels.nodes, pageInfo: issueLabels.pageInfo! } }) /** Gets one Linear issue label by ID. */ export const getLinearIssueLabel = defineAction("Get Linear issue label") .describe("Gets one Linear issue label by ID.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( z.object({ /** Stable Linear issue label ID. */ issueLabelId: z.string().min(1), }), ) .output(LINEAR_ISSUE_LABEL_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getLinearApi(account.secret) .graphql( `query LinearIssueLabel($id: String!) { issueLabel(id: $id) { ${LINEAR_ISSUE_LABEL_FIELDS} } }`, { responseSchema: ISSUE_LABEL_RESPONSE_SCHEMA, variables: { id: input.issueLabelId }, }, ) .then(({ issueLabel }) => issueLabel), ) /** * Builds common Relay pagination variables without serializing `undefined`. * * @param input - Validated pagination fields. * @param input.after - Cursor returned by the previous page. * @param input.includeArchived - Whether archived resources are included. * @param input.limit - Maximum resources in this page. */ function pageVariables(input: { after?: string includeArchived?: boolean limit: number }): JsonObject { return { first: input.limit, ...(input.after && { after: input.after }), ...(input.includeArchived !== undefined && { includeArchived: input.includeArchived, }), } }