Improve linear app (#20453)

- Add front component form to create linear issue
<img width="1512" height="831" alt="image"
src="https://github.com/user-attachments/assets/ffbb223f-30a8-4c64-ac6d-002c29b604c1"
/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/a5ed2464-35a9-4a60-804c-5f15eb0043b4"
/>

- improve marketplace Linear app page
<img width="1302" height="834" alt="image"
src="https://github.com/user-attachments/assets/cdec7ec2-953d-4a49-a797-5369834b03c1"
/>


- update admin settings to display non secret values
<img width="861" height="473" alt="image"
src="https://github.com/user-attachments/assets/41dadf02-aa5d-4eb6-befe-0ad8ad4049b2"
/>
This commit is contained in:
martmull
2026-05-12 18:03:31 +02:00
committed by GitHub
parent 70bb011daa
commit c4e897a7b5
36 changed files with 2145 additions and 436 deletions
@@ -1,9 +1,6 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { ABOUT_DESCRIPTION } from './constants/ABOUT_DESCRIPTION.md';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
@@ -11,10 +8,19 @@ export default defineApplication({
description:
'Connect Linear to Twenty. Each workspace member connects their own Linear account; logic functions can then create issues and read team data on their behalf.',
logoUrl: 'public/linear-logomark.svg',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
// OAuth client_id/secret live at the registration level (one OAuth app per
// Twenty server, configured by the server admin) — not per-workspace —
// so they're declared as serverVariables, not applicationVariables.
aboutDescription: ABOUT_DESCRIPTION,
applicationVariables: undefined,
author: 'Twenty',
category: 'Product management',
emailSupport: 'contact@twenty.com',
screenshots: [
'public/gallery/command-menu-item-1.png',
'public/gallery/command-menu-item-2.png',
'public/gallery/command-menu-item-3.png',
'public/gallery/command-menu-item-4.png',
],
termsUrl: 'https://github.com/twentyhq/twenty?tab=License-1-ov-file#readme',
websiteUrl: 'https://www.twenty.com',
serverVariables: {
LINEAR_CLIENT_ID: {
description:
@@ -0,0 +1,17 @@
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
CREATE_ISSUE_COMMAND_UNIVERSAL_IDENTIFIER,
CREATE_ISSUE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineCommandMenuItem({
universalIdentifier: CREATE_ISSUE_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Create Linear issue',
shortLabel: 'Linear issue',
icon: 'IconPlaylistAdd',
isPinned: false,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier:
CREATE_ISSUE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
@@ -15,8 +15,6 @@ export default defineConnectionProvider({
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
tokenRequestContentType: 'form-urlencoded',
// Linear supports PKCE but doesn't require it for confidential clients.
// Disabled to keep the test surface minimal.
usePkce: false,
usePkce: true,
},
});
@@ -0,0 +1,20 @@
export const ABOUT_DESCRIPTION = `Connect your Linear account to Twenty to create issues and look up teams straight from your workflows or the AI chat.
## What you can do
Once installed and connected, two tools become available:
- **Create Linear issue**
- From the AI chat, ask something like *"create a Linear issue in the Engineering team titled 'Fix login bug'"* and the AI will file it for you.
- From a workflow, add it as a step with \`teamId\` + \`title\` (and optional \`description\`).
- **List Linear teams** — discovers the teams in your Linear workspace, useful when you need to pick a \`teamId\` for the create-issue step.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Linear** in the available apps and click **Install**.
3. Open the app, go to the **Connections** tab, and click **Add connection**.
4. Choose **Just for me** (your personal Linear account) or **Workspace shared** (a team-managed Linear account anyone in this workspace can act through), then complete the Linear sign-in.
That's it — you can now use the tools above.`;
@@ -17,3 +17,24 @@ export const CREATE_LINEAR_ISSUE_UNIVERSAL_IDENTIFIER =
export const LIST_LINEAR_TEAMS_UNIVERSAL_IDENTIFIER =
'15824bbc-9c64-4f97-b45f-d0a44b402bb8';
export const LIST_LINEAR_TEAMS_ROUTE_UNIVERSAL_IDENTIFIER =
'a1d4e7b3-5c2f-4a89-9b0e-6f3d8c1a7e5b';
export const CREATE_LINEAR_ISSUE_ROUTE_UNIVERSAL_IDENTIFIER =
'c8f2a6d1-3b7e-4e09-8d5c-2a9f0b4e6c3d';
export const CREATE_ISSUE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'd4a1c8e5-7f3b-4d62-a90e-1b5c3f8d2a6e';
export const LIST_LINEAR_WORKFLOW_STATES_ROUTE_UNIVERSAL_IDENTIFIER =
'f5a2d8c1-4b7e-4f93-a6d0-9c3e1b8f5a2d';
export const LIST_LINEAR_ISSUE_OPTIONS_ROUTE_UNIVERSAL_IDENTIFIER =
'a3b7e1d9-8c4f-4a26-b5d0-7e9f2c6a8b3d';
export const LIST_LINEAR_ISSUE_OPTIONS_UNIVERSAL_IDENTIFIER =
'c2b99086-6c1b-43e4-9685-de4ade6bda63';
export const CREATE_ISSUE_COMMAND_UNIVERSAL_IDENTIFIER =
'e7b3d9f2-6a1c-4e85-b4d0-8f2c5a7e3b19';
@@ -0,0 +1,35 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/define';
import { CREATE_LINEAR_ISSUE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { createLinearIssueHandler } from 'src/logic-functions/handlers/create-linear-issue-handler';
const handler = async (event: RoutePayload) => {
const body = event.body as Record<string, unknown> | null;
return createLinearIssueHandler({
teamId: body?.teamId as string | undefined,
title: body?.title as string | undefined,
description: body?.description as string | undefined,
priority: body?.priority as number | undefined,
stateId: body?.stateId as string | undefined,
assigneeId: body?.assigneeId as string | undefined,
projectId: body?.projectId as string | undefined,
estimate: body?.estimate as number | undefined,
labelIds: body?.labelIds as string[] | undefined,
cycleId: body?.cycleId as string | undefined,
dueDate: body?.dueDate as string | undefined,
});
};
export default defineLogicFunction({
universalIdentifier: CREATE_LINEAR_ISSUE_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'create-linear-issue-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/linear/issues',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -27,6 +27,45 @@ export default defineLogicFunction({
type: 'string',
description: 'Optional issue description (Markdown supported).',
},
priority: {
type: 'integer',
description:
'Issue priority: 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low.',
minimum: 0,
maximum: 4,
},
stateId: {
type: 'string',
description:
'The workflow state ID for the issue status. Use list-linear-issue-options to discover available states for a team.',
},
assigneeId: {
type: 'string',
description:
'The user ID to assign the issue to. Use list-linear-issue-options to discover team members.',
},
projectId: {
type: 'string',
description: 'The project ID to associate the issue with.',
},
estimate: {
type: 'number',
description:
'The estimate value for the issue. Must match the team estimate scale.',
},
labelIds: {
type: 'array',
items: { type: 'string' },
description: 'Array of label IDs to apply to the issue.',
},
cycleId: {
type: 'string',
description: 'The cycle ID to add the issue to.',
},
dueDate: {
type: 'string',
description: 'Due date in YYYY-MM-DD format.',
},
},
required: ['teamId', 'title'],
},
@@ -40,6 +79,14 @@ export default defineLogicFunction({
teamId: { type: 'string' },
title: { type: 'string' },
description: { type: 'string' },
priority: { type: 'number' },
stateId: { type: 'string' },
assigneeId: { type: 'string' },
projectId: { type: 'string' },
estimate: { type: 'number' },
labelIds: { type: 'array', items: { type: 'string' } },
cycleId: { type: 'string' },
dueDate: { type: 'string' },
},
},
],
@@ -49,6 +49,17 @@ export const createLinearIssueHandler = async (
teamId: input.teamId,
title: input.title,
description: input.description,
...(input.priority !== undefined && { priority: input.priority }),
...(input.stateId !== undefined && { stateId: input.stateId }),
...(input.assigneeId !== undefined && {
assigneeId: input.assigneeId,
}),
...(input.projectId !== undefined && { projectId: input.projectId }),
...(input.estimate !== undefined && { estimate: input.estimate }),
...(input.labelIds !== undefined &&
input.labelIds.length > 0 && { labelIds: input.labelIds }),
...(input.cycleId !== undefined && { cycleId: input.cycleId }),
...(input.dueDate !== undefined && { dueDate: input.dueDate }),
},
},
});
@@ -0,0 +1,129 @@
import { listConnections } from 'twenty-sdk/logic-function';
import { callLinearGraphQL } from 'src/logic-functions/utils/call-linear-graphql';
type LinearMember = {
id: string;
name: string;
displayName: string;
};
type LinearProject = {
id: string;
name: string;
};
type LinearLabel = {
id: string;
name: string;
color: string;
};
type LinearCycle = {
id: string;
name: string | null;
number: number;
startsAt: string;
endsAt: string;
};
type LinearWorkflowState = {
id: string;
name: string;
type: string;
position: number;
};
type IssueOptionsQueryResult = {
team: {
states: { nodes: LinearWorkflowState[] };
members: { nodes: LinearMember[] };
cycles: { nodes: LinearCycle[] };
issueEstimationType: string;
issueEstimationAllowZero: boolean;
};
projects: { nodes: LinearProject[] };
issueLabels: { nodes: LinearLabel[] };
};
type IssueOptions = {
states: LinearWorkflowState[];
members: LinearMember[];
projects: LinearProject[];
labels: LinearLabel[];
cycles: LinearCycle[];
estimationType: string;
estimationAllowZero: boolean;
};
type HandlerResult =
| { success: true; options: IssueOptions }
| { success: false; error: string };
export const listLinearIssueOptionsHandler = async (input: {
teamId?: string;
}): Promise<HandlerResult> => {
if (!input.teamId) {
return { success: false, error: '`teamId` is required.' };
}
const connections = await listConnections({ providerName: 'linear' });
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection" first.',
};
}
const result = await callLinearGraphQL<IssueOptionsQueryResult>({
accessToken: connection.accessToken,
query: `
query IssueOptions($teamId: String!) {
team(id: $teamId) {
states { nodes { id name type position } }
members { nodes { id name displayName } }
cycles { nodes { id name number startsAt endsAt } }
issueEstimationType
issueEstimationAllowZero
}
projects { nodes { id name } }
issueLabels { nodes { id name color } }
}
`,
variables: { teamId: input.teamId },
});
if (result.errors || !result.data) {
return {
success: false,
error: result.errors?.[0]?.message ?? 'Unknown Linear API error',
};
}
const { team, projects, issueLabels } = result.data;
const now = new Date().toISOString();
const activeCycles = team.cycles.nodes.filter((c) => c.endsAt >= now);
return {
success: true,
options: {
states: team.states.nodes.sort((a, b) => a.position - b.position),
members: team.members.nodes.sort((a, b) =>
a.displayName.localeCompare(b.displayName),
),
projects: projects.nodes.sort((a, b) => a.name.localeCompare(b.name)),
labels: issueLabels.nodes.sort((a, b) => a.name.localeCompare(b.name)),
cycles: activeCycles.sort(
(a, b) =>
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
),
estimationType: team.issueEstimationType,
estimationAllowZero: team.issueEstimationAllowZero,
},
};
};
@@ -0,0 +1,63 @@
import { listConnections } from 'twenty-sdk/logic-function';
import { callLinearGraphQL } from 'src/logic-functions/utils/call-linear-graphql';
type LinearWorkflowState = {
id: string;
name: string;
type: string;
position: number;
};
type WorkflowStatesQueryResult = {
team: { states: { nodes: LinearWorkflowState[] } };
};
type HandlerResult =
| { success: true; states: LinearWorkflowState[] }
| { success: false; error: string };
export const listLinearWorkflowStatesHandler = async (input: {
teamId?: string;
}): Promise<HandlerResult> => {
if (!input.teamId) {
return { success: false, error: '`teamId` is required.' };
}
const connections = await listConnections({ providerName: 'linear' });
const connection =
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
if (!connection) {
return {
success: false,
error:
'Linear is not connected. Open the app settings and click "Add connection" first.',
};
}
const result = await callLinearGraphQL<WorkflowStatesQueryResult>({
accessToken: connection.accessToken,
query: `
query WorkflowStates($teamId: String!) {
team(id: $teamId) {
states { nodes { id name type position } }
}
}
`,
variables: { teamId: input.teamId },
});
if (result.errors || !result.data) {
return {
success: false,
error: result.errors?.[0]?.message ?? 'Unknown Linear API error',
};
}
const states = result.data.team.states.nodes.sort(
(a, b) => a.position - b.position,
);
return { success: true, states };
};
@@ -0,0 +1,23 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/define';
import { LIST_LINEAR_ISSUE_OPTIONS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { listLinearIssueOptionsHandler } from 'src/logic-functions/handlers/list-linear-issue-options-handler';
const handler = async (event: RoutePayload) => {
return listLinearIssueOptionsHandler({
teamId: event.queryStringParameters?.teamId,
});
};
export default defineLogicFunction({
universalIdentifier: LIST_LINEAR_ISSUE_OPTIONS_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'list-linear-issue-options-route',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/linear/issue-options',
httpMethod: 'GET',
isAuthRequired: true,
},
});
@@ -0,0 +1,110 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { LIST_LINEAR_ISSUE_OPTIONS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { listLinearIssueOptionsHandler } from 'src/logic-functions/handlers/list-linear-issue-options-handler';
export default defineLogicFunction({
universalIdentifier: LIST_LINEAR_ISSUE_OPTIONS_UNIVERSAL_IDENTIFIER,
name: 'list-linear-issue-options',
description:
'Returns available options for creating a Linear issue in a specific team: workflow states, members, projects, labels, cycles, and estimation settings. Requires a teamId (call list-linear-teams to discover one).',
timeoutSeconds: 15,
handler: listLinearIssueOptionsHandler,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
teamId: {
type: 'string',
description:
'The Linear team ID to fetch issue options for. Use list-linear-teams to discover available teams.',
},
},
required: ['teamId'],
},
},
workflowActionTriggerSettings: {
label: 'List Linear Issue Options',
inputSchema: [
{
type: 'object',
properties: {
teamId: { type: 'string' },
},
},
],
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
options: {
type: 'object',
properties: {
states: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
type: { type: 'string' },
position: { type: 'number' },
},
},
},
members: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
displayName: { type: 'string' },
},
},
},
projects: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
},
},
labels: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
color: { type: 'string' },
},
},
},
cycles: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
number: { type: 'number' },
startsAt: { type: 'string' },
endsAt: { type: 'string' },
},
},
},
estimationType: { type: 'string' },
estimationAllowZero: { type: 'boolean' },
},
},
error: { type: 'string' },
},
},
],
},
});
@@ -0,0 +1,21 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/define';
import { LIST_LINEAR_TEAMS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { listLinearTeamsHandler } from 'src/logic-functions/handlers/list-linear-teams-handler';
const handler = async (_event: RoutePayload) => {
return listLinearTeamsHandler();
};
export default defineLogicFunction({
universalIdentifier: LIST_LINEAR_TEAMS_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'list-linear-teams-route',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/linear/teams',
httpMethod: 'GET',
isAuthRequired: true,
},
});
@@ -16,4 +16,33 @@ export default defineLogicFunction({
properties: {},
},
},
workflowActionTriggerSettings: {
label: 'List Linear Teams',
inputSchema: [
{
type: 'object',
properties: {},
},
],
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
teams: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
key: { type: 'string' },
},
},
},
error: { type: 'string' },
},
},
],
},
});
@@ -0,0 +1,23 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/define';
import { LIST_LINEAR_WORKFLOW_STATES_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { listLinearWorkflowStatesHandler } from 'src/logic-functions/handlers/list-linear-workflow-states-handler';
const handler = async (event: RoutePayload) => {
return listLinearWorkflowStatesHandler({
teamId: event.queryStringParameters?.teamId,
});
};
export default defineLogicFunction({
universalIdentifier: LIST_LINEAR_WORKFLOW_STATES_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'list-linear-workflow-states-route',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/linear/workflow-states',
httpMethod: 'GET',
isAuthRequired: true,
},
});
@@ -2,4 +2,12 @@ export type CreateIssueInput = {
teamId?: string;
title?: string;
description?: string;
priority?: number;
stateId?: string;
assigneeId?: string;
projectId?: string;
estimate?: number;
labelIds?: string[];
cycleId?: string;
dueDate?: string;
};
@@ -1,10 +1,8 @@
import { defineRole } from 'twenty-sdk/define';
import { defineApplicationRole } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
// The Linear logic functions never read workspace data — they only call
// Linear's GraphQL API on behalf of the connected user.
export default defineRole({
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Linear function role',
description: 'No-op role for Linear logic functions',