diff --git a/package.json b/package.json index c0cf5aa..fe26d73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linear-zapier", - "version": "4.3.1", + "version": "4.4.0", "description": "Linear's Zapier integration", "main": "index.js", "license": "MIT", diff --git a/src/creates/updateIssue.ts b/src/creates/updateIssue.ts new file mode 100644 index 0000000..70d767a --- /dev/null +++ b/src/creates/updateIssue.ts @@ -0,0 +1,240 @@ +import { Bundle, ZObject } from "zapier-platform-core"; +import { fetchFromLinear } from "../fetchFromLinear"; +import { omitBy, uniq } from "lodash"; + +interface IssueUpdateResponse { + data?: { + issueUpdate: { + issue: { + id: string; + title: string; + url: string; + identifier: string; + }; + success: boolean; + }; + }; + errors?: { + message: string; + extensions?: { + userPresentableMessage?: string; + }; + }[]; +} + +interface IssueResponse { + data: { issue: { labelIds: string[] } }; +} + +const updateIssueRequest = async (z: ZObject, bundle: Bundle) => { + if (!bundle.inputData.issueIdToUpdate) { + throw new z.errors.HaltedError("You must specify the ID of the issue to update"); + } + const priority = bundle.inputData.priority ? parseInt(bundle.inputData.priority) : 0; + const estimate = bundle.inputData.estimate ? parseInt(bundle.inputData.estimate) : undefined; + let labelIds: string[] | undefined = undefined; + if (bundle.inputData.labels && bundle.inputData.labels.length > 0) { + // We need to append new labels to the issue's existing label set + const issueQuery = ` + query ZapierIssue($id: String!) { + issue(id: $id) { + id + labelIds + } + } + `; + const response = await fetchFromLinear(z, bundle, issueQuery, { id: bundle.inputData.issueIdToUpdate }); + const data = response.json as IssueResponse; + const originalLabelIds = data.data.issue.labelIds; + labelIds = uniq([...originalLabelIds, ...bundle.inputData.labels]); + } + + const variables = omitBy( + { + issueIdToUpdate: bundle.inputData.issueIdToUpdate, + teamId: bundle.inputData.teamId, + title: bundle.inputData.title, + description: bundle.inputData.description, + priority: priority, + estimate: estimate, + stateId: bundle.inputData.statusId, + parentId: bundle.inputData.parentId, + assigneeId: bundle.inputData.assigneeId, + projectId: bundle.inputData.projectId, + projectMilestoneId: bundle.inputData.projectMilestoneId, + dueDate: bundle.inputData.dueDate, + labelIds, + }, + (v) => v === undefined + ); + + const query = ` + mutation ZapierIssueUpdate( + $issueIdToUpdate: String!, + $teamId: String, + $title: String, + $description: String, + $priority: Int, + $estimate: Int, + $stateId: String, + $parentId: String, + $assigneeId: String, + $projectId: String, + $projectMilestoneId: String, + $dueDate: TimelessDate, + $labelIds: [String!] + ) { + issueUpdate(id: $issueIdToUpdate, input: { + teamId: $teamId, + title: $title, + description: $description, + priority: $priority, + estimate: $estimate, + stateId: $stateId, + parentId: $parentId, + assigneeId: $assigneeId, + projectId: $projectId, + projectMilestoneId: $projectMilestoneId, + dueDate: $dueDate, + labelIds: $labelIds + }) { + issue { + id + identifier + title + url + } + success + } + }`; + + const response = await fetchFromLinear(z, bundle, query, variables); + const data = response.json as IssueUpdateResponse; + if (data.errors && data.errors.length) { + const error = data.errors[0]; + throw new z.errors.Error( + (error.extensions && error.extensions.userPresentableMessage) || error.message, + "invalid_input", + 400 + ); + } + + if (data.data && data.data.issueUpdate && data.data.issueUpdate.success) { + return data.data.issueUpdate.issue; + } else { + const error = data.errors ? data.errors[0].message : "Something went wrong"; + throw new z.errors.Error("Failed to update the issue", error, 400); + } +}; + +export const updateIssue = { + key: "updateIssue", + display: { + hidden: false, + description: "Update an existing issue in Linear", + label: "Update Issue", + }, + noun: "Issue", + operation: { + perform: updateIssueRequest, + inputFields: [ + { + label: "Issue ID", + key: "issueIdToUpdate", + helpText: "The ID of the issue to update", + required: true, + altersDynamicFields: true, + }, + { + label: "Team", + key: "teamId", + helpText: "The team to move the issue to. If this is left blank, the issue will stay in its current team.", + dynamic: "team.id.name", + altersDynamicFields: true, + }, + { + label: "Title", + helpText: "The new title of the issue", + key: "title", + }, + { + label: "Description", + helpText: "The new description of the issue in markdown format", + key: "description", + type: "text", + }, + { + label: "Parent Issue", + helpText: "The ID of the parent issue to set", + type: "string", + key: "parentId", + }, + { + label: "Status", + helpText: + "The new status of the issue. If you're moving the issue to a new team, this list will be populated with the statuses of the new team.", + key: "statusId", + dynamic: "status.id.name", + }, + { + label: "Assignee", + helpText: "The new assignee of the issue", + key: "assigneeId", + dynamic: "user.id.name", + }, + { + label: "Priority", + helpText: "The new priority of the issue", + key: "priority", + choices: [ + { value: "0", sample: "0", label: "No priority" }, + { value: "1", sample: "1", label: "Urgent" }, + { value: "2", sample: "2", label: "High" }, + { value: "3", sample: "3", label: "Medium" }, + { value: "4", sample: "4", label: "Low" }, + ], + }, + { + label: "Estimate", + helpText: "The new estimate of the issue", + key: "estimate", + dynamic: "estimate.id.label", + }, + { + label: "Labels", + helpText: + "Labels to add to the issue. If you're moving the issue to a new team, this list will be populated with workspace labels and labels of the new team.", + key: "labels", + dynamic: "label.id.name", + list: true, + }, + { + label: "Project", + helpText: + "The project to move the issue to. If you're moving the issue to a new team, this list will be populated with the projects of the new team.", + key: "projectId", + dynamic: "project.id.name", + }, + { + label: "Project Milestone", + helpText: "The project milestone to move the issue to", + key: "projectMilestoneId", + dynamic: "project_milestone.id.name", + }, + { + label: "Due Date", + helpText: "The issue due date in `yyyy-MM-dd` format", + key: "dueDate", + type: "string", + }, + ], + sample: { + data: { + id: "7b647c45-c528-464d-8634-eecea0f73033", + title: "Do the roar", + url: "https://linear.app/linear/issue/ENG-118/do-the-roar", + identifier: "ENG-118", + }, + }, + }, +}; diff --git a/src/fetchFromLinear.ts b/src/fetchFromLinear.ts index 3c80b99..c87e9f5 100644 --- a/src/fetchFromLinear.ts +++ b/src/fetchFromLinear.ts @@ -23,3 +23,30 @@ export const fetchFromLinear = async ( method: "POST", }); }; + +/** + * Retrieves the team ID for an issue from Linear. + * + * @param z Zapier object + * @param bundle Zapier bundle + * @param issueId Linear issue ID + * @returns The Linear team ID for the issue + */ +export const getIssueTeamId = async (z: ZObject, bundle: Bundle, issueId: string) => { + const issueQuery = ` + query ZapierIssue($id: String!) { + issue(id: $id) { + team { + id + } + } + } + `; + const response = await fetchFromLinear(z, bundle, issueQuery, { id: issueId }); + const data = response.json as IssueResponse; + return data.data.issue.team.id; +}; + +interface IssueResponse { + data: { issue: { team: { id: string } } }; +} diff --git a/src/index.ts b/src/index.ts index 57056cf..31c810f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { projectStatus } from "./triggers/projectStatus"; import { newProjectInstant } from "./triggers/newProject"; import { createIssueAttachment } from "./creates/createIssueAttachment"; import { createProject } from "./creates/createProject"; +import { updateIssue } from "./creates/updateIssue"; const handleErrors = (response: HttpResponse, z: ZObject) => { if (response.request.url !== "https://api.linear.app/graphql") { @@ -52,6 +53,7 @@ const App = { [createComment.key]: createComment, [createIssueAttachment.key]: createIssueAttachment, [createProject.key]: createProject, + [updateIssue.key]: updateIssue, }, triggers: { [newIssue.key]: newIssue, diff --git a/src/triggers/label.ts b/src/triggers/label.ts index fef3a05..e9f9b44 100644 --- a/src/triggers/label.ts +++ b/src/triggers/label.ts @@ -1,4 +1,5 @@ import { ZObject, Bundle } from "zapier-platform-core"; +import { getIssueTeamId } from "../fetchFromLinear"; type LabelResponse = { id: string; @@ -17,9 +18,13 @@ type LabelsResponse = { }; const getLabelList = async (z: ZObject, bundle: Bundle) => { - const teamId = bundle.inputData.teamId || bundle.inputData.team_id; + let teamId = bundle.inputData.teamId || bundle.inputData.team_id; + if (!teamId && bundle.inputData.issueIdToUpdate) { + // For the `updateIssue` action, we populate the labels dropdown using the issue's current team if the zap isn't updating the issue's team + teamId = await getIssueTeamId(z, bundle, bundle.inputData.issueIdToUpdate); + } if (!teamId) { - throw new z.errors.HaltedError(`Please select the team first`); + throw new z.errors.HaltedError("Please select the team first before selecting the labels"); } const cursor = bundle.meta.page ? await z.cursor.get() : undefined; diff --git a/src/triggers/project.ts b/src/triggers/project.ts index 889c9c6..9d721dc 100644 --- a/src/triggers/project.ts +++ b/src/triggers/project.ts @@ -1,4 +1,5 @@ import { ZObject, Bundle } from "zapier-platform-core"; +import { getIssueTeamId } from "../fetchFromLinear"; interface TeamProjectsResponse { data: { @@ -23,9 +24,13 @@ interface TeamProjectsResponse { } const getProjectList = async (z: ZObject, bundle: Bundle) => { - const teamId = bundle.inputData.teamId || bundle.inputData.team_id; + let teamId = bundle.inputData.teamId || bundle.inputData.team_id; + if (!teamId && bundle.inputData.issueIdToUpdate) { + // For the `updateIssue` action, we populate the project dropdown using the issue's current team if the zap isn't updating the issue's team + teamId = await getIssueTeamId(z, bundle, bundle.inputData.issueIdToUpdate); + } if (!teamId) { - throw new z.errors.HaltedError(`Please select the team first`); + throw new z.errors.HaltedError("Please select the team first before selecting the project"); } const cursor = bundle.meta.page ? await z.cursor.get() : undefined; diff --git a/src/triggers/status.ts b/src/triggers/status.ts index 733bb89..f5ac38b 100644 --- a/src/triggers/status.ts +++ b/src/triggers/status.ts @@ -1,4 +1,5 @@ import { ZObject, Bundle } from "zapier-platform-core"; +import { getIssueTeamId } from "../fetchFromLinear"; interface TeamStatesResponse { data: { @@ -19,9 +20,13 @@ interface TeamStatesResponse { } const getStatusList = async (z: ZObject, bundle: Bundle) => { - const teamId = bundle.inputData.teamId || bundle.inputData.team_id; + let teamId = bundle.inputData.teamId || bundle.inputData.team_id; + if (!teamId && bundle.inputData.issueIdToUpdate) { + // For the `updateIssue` action, we populate the status dropdown using the issue's current team if the zap isn't updating the issue's team + teamId = await getIssueTeamId(z, bundle, bundle.inputData.issueIdToUpdate); + } if (!teamId) { - throw new z.errors.HaltedError(`Please select the team first`); + throw new z.errors.HaltedError("Please select the team first before selecting the status"); } const cursor = bundle.meta.page ? await z.cursor.get() : undefined;