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_FIELDS } from "../lib/fragments" import { LINEAR_ISSUE_SCHEMA, LINEAR_PAGE_INFO_SCHEMA, LINEAR_PAGE_INPUT_SCHEMA, LINEAR_PROVIDER_ISSUE_SCHEMA, linearConnectionSchema, toLinearIssue, } from "@automate.ax/integration-contracts/linear" import { LINEAR_ISSUE_CREATE_ACCOUNT_OPTIONS, LINEAR_READ_ACCOUNT_OPTIONS, LINEAR_WRITE_ACCOUNT_OPTIONS, } from "../lib/scopes" const ISSUE_RESPONSE_SCHEMA = z.object({ issue: LINEAR_PROVIDER_ISSUE_SCHEMA, }) const ISSUE_CONNECTION_SCHEMA = linearConnectionSchema( LINEAR_PROVIDER_ISSUE_SCHEMA, ) const ISSUE_PAGE_SCHEMA = z.object({ issues: LINEAR_ISSUE_SCHEMA.array(), pageInfo: LINEAR_PAGE_INFO_SCHEMA, }) const ISSUE_MUTATION_RESPONSE_SCHEMA = z.object({ issueMutation: z.object({ issue: LINEAR_PROVIDER_ISSUE_SCHEMA, success: z.literal(true), }), }) const ISSUE_ARCHIVE_RESPONSE_SCHEMA = z.object({ issueMutation: z.object({ entity: LINEAR_PROVIDER_ISSUE_SCHEMA, success: z.literal(true), }), }) const ISSUE_PRIORITY_SCHEMA = z.number().int().min(0).max(4) const ISSUE_CREATE_INPUT_SCHEMA = z.object({ /** User assigned to the new issue. */ assigneeId: z.string().min(1).optional(), /** Cycle containing the new issue. */ cycleId: z.string().min(1).optional(), /** Markdown issue description. */ description: z.string().optional(), /** Due date in `YYYY-MM-DD` format. */ dueDate: z.iso.date().optional(), /** Estimate value supported by the team's estimate scale. */ estimate: z.number().int().nonnegative().optional(), /** Initial issue labels. */ labelIds: z.string().min(1).array().optional(), /** Parent issue for a new sub-issue. */ parentId: z.string().min(1).optional(), /** Priority from 0 (no priority) to 4 (low). */ priority: ISSUE_PRIORITY_SCHEMA.optional(), /** Project containing the new issue. */ projectId: z.string().min(1).optional(), /** Initial workflow state; Linear uses the team's default when omitted. */ stateId: z.string().min(1).optional(), /** Users subscribed to issue activity. */ subscriberIds: z.string().min(1).array().optional(), /** Team that owns the new issue. */ teamId: z.string().min(1), /** Issue title. */ title: z.string().min(1), }) const ISSUE_UPDATE_FIELDS_SCHEMA = z.object({ /** Replace the issue's assignee, or pass `null` to unassign it. */ assigneeId: z.string().min(1).nullable().optional(), /** Add labels without replacing the existing label set. */ addedLabelIds: z.string().min(1).array().optional(), /** Replace the issue's cycle, or pass `null` to remove it. */ cycleId: z.string().min(1).nullable().optional(), /** Replace the Markdown description, or pass `null` to clear it. */ description: z.string().nullable().optional(), /** Delegate the issue to an agent, or pass `null` to remove delegation. */ delegateId: z.string().min(1).nullable().optional(), /** Replace the due date, or pass `null` to clear it. */ dueDate: z.iso.date().nullable().optional(), /** Replace the estimate, or pass `null` to clear it. */ estimate: z.number().int().nonnegative().nullable().optional(), /** Replace the complete issue label set. */ labelIds: z.string().min(1).array().optional(), /** Replace the parent issue, or pass `null` to make it top-level. */ parentId: z.string().min(1).nullable().optional(), /** Priority from 0 (no priority) to 4 (low). */ priority: ISSUE_PRIORITY_SCHEMA.optional(), /** Replace the project, or pass `null` to remove it. */ projectId: z.string().min(1).nullable().optional(), /** Remove selected labels without replacing the remaining label set. */ removedLabelIds: z.string().min(1).array().optional(), /** Move the issue to another workflow state. */ stateId: z.string().min(1).optional(), /** Replace the complete subscriber set. */ subscriberIds: z.string().min(1).array().optional(), /** Move the issue to another team. */ teamId: z.string().min(1).optional(), /** Replace the issue title. */ title: z.string().min(1).optional(), }) /** Lists Linear issues using common code-first filters and Relay pagination. */ export const listLinearIssues = defineAction("List Linear issues") .describe("Lists a cursor-paginated page of filtered Linear issues.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( LINEAR_PAGE_INPUT_SCHEMA.extend({ /** User assigned to returned issues. */ assigneeId: z.string().min(1).optional(), /** Only issues created at or after this timestamp. */ createdAtOrAfter: z.iso.datetime({ offset: true }).optional(), /** Label that returned issues must contain. */ labelId: z.string().min(1).optional(), /** Sort by creation time or most recent update. */ orderBy: z.enum(["createdAt", "updatedAt"]).prefault("updatedAt"), /** Parent issue whose direct sub-issues should be returned. */ parentId: z.string().min(1).optional(), /** Project containing returned issues. */ projectId: z.string().min(1).optional(), /** Text contained in Linear's searchable issue content. */ search: z.string().min(1).optional(), /** Workflow state containing returned issues. */ stateId: z.string().min(1).optional(), /** Team containing returned issues. */ teamId: z.string().min(1).optional(), /** Only issues updated at or after this timestamp. */ updatedAtOrAfter: z.iso.datetime({ offset: true }).optional(), }), ) .output(ISSUE_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const filter = issueFilter(input) const { issues } = await getLinearApi(account.secret).graphql( `query LinearIssues( $first: Int! $after: String $includeArchived: Boolean $orderBy: PaginationOrderBy! $filter: IssueFilter ) { issues( first: $first after: $after includeArchived: $includeArchived orderBy: $orderBy filter: $filter ) { nodes { ${LINEAR_ISSUE_FIELDS} } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }`, { responseSchema: z.object({ issues: ISSUE_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 { issues: issues.nodes.map(toLinearIssue), pageInfo: issues.pageInfo!, } }) /** Gets one Linear issue by UUID or human-readable identifier. */ export const getLinearIssue = defineAction("Get Linear issue") .describe("Gets one Linear issue by UUID or identifier such as `ENG-123`.") .account("linear", LINEAR_READ_ACCOUNT_OPTIONS) .input( z.object({ /** Stable issue UUID or human-readable identifier. */ issueId: z.string().min(1), }), ) .output(LINEAR_ISSUE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getLinearApi(account.secret) .graphql( `query LinearIssue($id: String!) { issue(id: $id) { ${LINEAR_ISSUE_FIELDS} } }`, { responseSchema: ISSUE_RESPONSE_SCHEMA, variables: { id: input.issueId }, }, ) .then(({ issue }) => toLinearIssue(issue)), ) /** Creates a Linear issue with optional assignment, planning, and label data. */ export const createLinearIssue = defineAction("Create Linear issue") .describe("Creates a Linear issue and returns its normalized provider state.") .account("linear", LINEAR_ISSUE_CREATE_ACCOUNT_OPTIONS) .input(ISSUE_CREATE_INPUT_SCHEMA) .output(LINEAR_ISSUE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => mutateIssue(account.secret, "issueCreate", { input }), ) /** Delegates a Linear issue to an app agent such as Codex. */ export const delegateLinearIssue = defineAction("Delegate Linear issue") .describe("Delegates an issue to a Linear coding-session agent.") .account("linear", LINEAR_WRITE_ACCOUNT_OPTIONS) .input( z.object({ /** Agent app-user ID returned by `listLinearUsers`. */ delegateId: z.string().min(1), /** UUID or human-readable identifier of the issue to delegate. */ issueId: z.string().min(1), }), ) .output(LINEAR_ISSUE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => mutateIssue(account.secret, "issueUpdate", { id: input.issueId, input: { delegateId: input.delegateId }, }), ) /** Updates editable fields on an existing Linear issue. */ export const updateLinearIssue = defineAction("Update Linear issue") .describe("Updates assignment, planning, labels, workflow, or content.") .account("linear", LINEAR_WRITE_ACCOUNT_OPTIONS) .input( ISSUE_UPDATE_FIELDS_SCHEMA.extend({ /** UUID or human-readable identifier of the issue to update. */ issueId: z.string().min(1), }), ) .output(LINEAR_ISSUE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { issueId, ...update } = input return mutateIssue(account.secret, "issueUpdate", { id: issueId, input: update, }) }) /** Archives a Linear issue or moves it to Linear's recently deleted view. */ export const archiveLinearIssue = defineAction("Archive Linear issue") .describe( "Archives a Linear issue, optionally moving it to recently deleted.", ) .account("linear", LINEAR_WRITE_ACCOUNT_OPTIONS) .input( z.object({ /** UUID or human-readable issue identifier. */ issueId: z.string().min(1), /** Move the issue to recently deleted instead of the archive. */ trash: z.boolean().optional(), }), ) .output(LINEAR_ISSUE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { issueMutation } = await getLinearApi(account.secret).graphql( `mutation LinearIssueArchive($id: String!, $trash: Boolean) { issueMutation: issueArchive(id: $id, trash: $trash) { success entity { ${LINEAR_ISSUE_FIELDS} } } }`, { responseSchema: ISSUE_ARCHIVE_RESPONSE_SCHEMA, variables: { id: input.issueId, ...(input.trash !== undefined && { trash: input.trash }), }, }, ) return toLinearIssue(issueMutation.entity) }) /** Restores an archived Linear issue. */ export const unarchiveLinearIssue = defineAction("Unarchive Linear issue") .describe("Restores an archived Linear issue to its team workflow.") .account("linear", LINEAR_WRITE_ACCOUNT_OPTIONS) .input( z.object({ /** Stable issue UUID or human-readable identifier. */ issueId: z.string().min(1), }), ) .output(LINEAR_ISSUE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { issueMutation } = await getLinearApi(account.secret).graphql( `mutation LinearIssueUnarchive($id: String!) { issueMutation: issueUnarchive(id: $id) { success entity { ${LINEAR_ISSUE_FIELDS} } } }`, { responseSchema: ISSUE_ARCHIVE_RESPONSE_SCHEMA, variables: { id: input.issueId }, }, ) return toLinearIssue(issueMutation.entity) }) /** * Executes one Linear issue create or update mutation. * * @param secret - Resolved Linear integration secret. * @param mutation - Provider mutation to execute. * @param variables - GraphQL mutation variables. */ async function mutateIssue( secret: Record, mutation: "issueCreate" | "issueUpdate", variables: JsonObject, ) { const { issueMutation } = await getLinearApi(secret).graphql( mutation === "issueCreate" ? `mutation LinearIssueCreate($input: IssueCreateInput!) { issueMutation: issueCreate(input: $input) { success issue { ${LINEAR_ISSUE_FIELDS} } } }` : `mutation LinearIssueUpdate($id: String!, $input: IssueUpdateInput!) { issueMutation: issueUpdate(id: $id, input: $input) { success issue { ${LINEAR_ISSUE_FIELDS} } } }`, { responseSchema: ISSUE_MUTATION_RESPONSE_SCHEMA, variables }, ) return toLinearIssue(issueMutation.issue) } /** * Builds a provider-native Linear issue filter from public action fields. * * @param input - Validated public issue filters. * @param input.assigneeId - Required assignee ID. * @param input.createdAtOrAfter - Earliest creation timestamp. * @param input.labelId - Required issue label ID. * @param input.parentId - Required parent issue ID. * @param input.projectId - Required project ID. * @param input.search - Searchable-content substring. * @param input.stateId - Required workflow state ID. * @param input.teamId - Required team ID. * @param input.updatedAtOrAfter - Earliest update timestamp. */ function issueFilter(input: { assigneeId?: string createdAtOrAfter?: string labelId?: string parentId?: string projectId?: string search?: string stateId?: string teamId?: string updatedAtOrAfter?: string }): JsonObject { return { ...(input.assigneeId && { assignee: { id: { eq: input.assigneeId } } }), ...(input.createdAtOrAfter && { createdAt: { gte: input.createdAtOrAfter }, }), ...(input.labelId && { labels: { some: { id: { eq: input.labelId } } }, }), ...(input.parentId && { parent: { id: { eq: input.parentId } } }), ...(input.projectId && { project: { id: { eq: input.projectId } } }), ...(input.search && { searchableContent: { contains: input.search } }), ...(input.stateId && { state: { id: { eq: input.stateId } } }), ...(input.teamId && { team: { id: { eq: input.teamId } } }), ...(input.updatedAtOrAfter && { updatedAt: { gte: input.updatedAtOrAfter }, }), } }